diff --git a/.gitea/workflows/android.yaml b/.gitea/workflows/android.yaml index 8a0bf8f..f474d21 100644 --- a/.gitea/workflows/android.yaml +++ b/.gitea/workflows/android.yaml @@ -72,6 +72,7 @@ jobs: "cmake;3.22.1" echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> $GITHUB_ENV - name: Cache Gradle wrapper uses: actions/cache@v3 @@ -112,8 +113,15 @@ jobs: ./gradlew --no-daemon --version + - name: Configure local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "ndk.dir=$ANDROID_HOME/ndk/26.1.10909125" >> local.properties + echo "cmake.dir=$ANDROID_HOME/cmake/3.22.1" >> local.properties + cat local.properties + - name: Build Release APK - run: ./gradlew --no-daemon assembleRelease + run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease - name: Check if APK exists run: | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9f660bb..ca23299 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,26 @@ # Release Notes +## 1.4.2 + +### Звонки +- Полноэкранный incoming call через ForegroundService — кнопки Accept/Decline, будит экран, работает когда приложение свёрнуто или убито (и из push, и из WebSocket). +- Синхронизация ForegroundService с фазами звонка — notification обновляется при INCOMING → CONNECTING → ACTIVE → IDLE. +- Защита от CREATE_ROOM без ключей шифрования — сброс сессии если звонок принят на другом устройстве. +- Корректное освобождение PeerConnection (`dispose()`) при завершении звонка — фикс зависания ICE портов ~30 сек. + +### E2EE диагностика +- Диагностический файл E2EE включён для всех билдов (был только debug). +- Периодический health-лог E2EE с счётчиками фреймов enc/dec из нативного кода. +- Уменьшен спам scan receivers — логирование только при изменении состояния. +- Нативные методы `FrameCount()` / `BadStreak()` для мониторинга шифрования в реальном времени. + +### Push-уведомления +- Добавлены `tokenType` и `deviceId` в пакет push-подписки (совместимость с новым сервером). +- Сохранение FCM токена в crash_reports для просмотра через rosettadev1. + +### CI/CD +- Установка NDK и CMake в CI для сборки нативного модуля `rosetta_e2ee.so`. + ## 1.3.4 ### Звонки и UI diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f397406..5f6e111 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.4.2" -val rosettaVersionCode = 44 // Increment on each release +val rosettaVersionName = "1.4.3" +val rosettaVersionCode = 45 // Increment on each release val customWebRtcAar = file("libs/libwebrtc-custom.aar") android { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2dea42..1503d11 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,8 +10,11 @@ + + + @@ -43,14 +46,27 @@ android:launchMode="singleTask" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout" android:windowSoftInputMode="adjustResize" - android:screenOrientation="portrait"> + android:screenOrientation="portrait" + android:showWhenLocked="true" + android:turnScreenOn="true"> - + + + + android:foregroundServiceType="microphone|mediaPlayback|phoneCall" /> = Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + } + + // Dismiss keyguard + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + km?.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) + } + + try { + CallManager.initialize(applicationContext) + callLog("CallManager initialized, phase=${CallManager.state.value.phase}") + } catch (e: Throwable) { + callLog("CallManager.initialize CRASHED: ${e.message}") + Log.e(TAG, "CallManager init failed", e) + } + + callLog("calling setContent") + setContent { + val callState by CallManager.state.collectAsState() + + // Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE) + var wasIncoming by remember { mutableStateOf(false) } + + LaunchedEffect(callState.phase) { + callLog("phase changed: ${callState.phase}") + if (callState.phase == CallPhase.INCOMING) wasIncoming = true + // Закрываем только если звонок реально начался и потом завершился + if (callState.phase == CallPhase.IDLE && wasIncoming) { + callLog("IDLE after INCOMING → finish()") + finish() + } else if (callState.phase == CallPhase.CONNECTING || + callState.phase == CallPhase.ACTIVE) { + callLog("${callState.phase} → openMainActivity + finish") + openMainActivity() + finish() + } + } + + // Показываем INCOMING даже если CallManager ещё в IDLE (push раньше WebSocket) + val displayState = if (callState.phase == CallPhase.IDLE) { + callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...") + } else callState + + RosettaAndroidTheme(darkTheme = true) { + CallOverlay( + state = displayState, + isDarkTheme = true, + isExpanded = true, + onAccept = { + callLog("onAccept tapped, phase=${callState.phase}") + if (callState.phase == CallPhase.INCOMING) { + val result = CallManager.acceptIncomingCall() + callLog("acceptIncomingCall result=$result") + if (result == CallActionResult.STARTED) { + openMainActivity() + finish() + } + } else { + callLog("onAccept: phase not INCOMING yet, waiting...") + // WebSocket ещё не доставил CALL — открываем MainActivity, + // она подождёт и примет звонок + openMainActivity() + finish() + } + }, + onDecline = { + callLog("onDecline tapped") + CallManager.declineIncomingCall() + finish() + }, + onEnd = { + callLog("onEnd tapped") + CallManager.endCall() + finish() + }, + onToggleMute = { CallManager.toggleMute() }, + onToggleSpeaker = { CallManager.toggleSpeaker() } + ) + } + } + } + + private fun openMainActivity() { + callLog("openMainActivity") + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) + } + startActivity(intent) + } + + private fun callLog(msg: String) { + Log.d(TAG, msg) + try { + val ctx = applicationContext ?: return + val dir = java.io.File(ctx.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, "call_notification_log.txt") + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + f.appendText("$ts [IncomingCallActivity] $msg\n") + } catch (e: Throwable) { + Log.e(TAG, "callLog write failed: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 9eefbf8..2b5e414 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1,6 +1,8 @@ package com.rosetta.messenger // commit import android.Manifest +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -83,6 +85,10 @@ class MainActivity : FragmentActivity() { private lateinit var preferencesManager: PreferencesManager private lateinit var accountManager: AccountManager + // Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth + // mutableStateOf чтобы Compose реагировал на изменение (избежать race condition) + private var openedForCall by mutableStateOf(false) + companion object { private const val TAG = "MainActivity" // Process-memory session cache: lets app return without password while process is alive. @@ -120,6 +126,7 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + handleCallLockScreen(intent) preferencesManager = PreferencesManager(this) accountManager = AccountManager(this) @@ -161,6 +168,21 @@ class MainActivity : FragmentActivity() { ) } } + + // Android 14+: запрос fullScreenIntent для входящих звонков + if (Build.VERSION.SDK_INT >= 34) { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + if (!nm.canUseFullScreenIntent()) { + try { + startActivity( + android.content.Intent( + android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + android.net.Uri.parse("package:$packageName") + ) + ) + } catch (_: Throwable) {} + } + } } val scope = rememberCoroutineScope() @@ -212,6 +234,9 @@ class MainActivity : FragmentActivity() { showSplash -> "splash" showOnboarding && hasExistingAccount == false -> "onboarding" + // При открытии по звонку с lock screen — пропускаем auth + openedForCall && hasExistingAccount == true -> + "main" isLoggedIn != true && hasExistingAccount == false -> "auth_new" isLoggedIn != true && hasExistingAccount == true -> @@ -433,6 +458,66 @@ class MainActivity : FragmentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleCallLockScreen(intent) + } + + private var callLockScreenJob: kotlinx.coroutines.Job? = null + + /** + * Показать Activity поверх экрана блокировки при входящем звонке. + * При завершении звонка флаги снимаются чтобы не нарушать обычное поведение. + */ + private fun handleCallLockScreen(intent: Intent?) { + val isCallIntent = intent?.getBooleanExtra( + com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false + ) == true + if (isCallIntent) { + openedForCall = true + // Включаем экран и показываем поверх lock screen + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + // Убираем lock screen полностью + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager + keyguardManager?.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) + } + // Снять флаги когда звонок закончится (отменяем предыдущий коллектор если был) + callLockScreenJob?.cancel() + callLockScreenJob = lifecycleScope.launch { + com.rosetta.messenger.network.CallManager.state.collect { state -> + if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) { + openedForCall = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + @Suppress("DEPRECATION") + window.clearFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + callLockScreenJob?.cancel() + callLockScreenJob = null + } + } + } + } + } + override fun onResume() { super.onResume() // 🔥 Приложение стало видимым - отключаем уведомления @@ -1347,7 +1432,8 @@ fun MainScreen( chatWallpaperId = chatWallpaperId, avatarRepository = avatarRepository, onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }, - isCallActive = callUiState.isVisible + isCallActive = callUiState.isVisible, + onOpenCallOverlay = { isCallOverlayExpanded = true } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index aa02e85..8879c92 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -36,6 +36,7 @@ class PreferencesManager(private val context: Context) { val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled") val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled") val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled") + val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled") // Chat Settings val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large @@ -143,6 +144,11 @@ class PreferencesManager(private val context: Context) { preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true } + val notificationAvatarEnabled: Flow = + context.dataStore.data.map { preferences -> + preferences[NOTIFICATION_AVATAR_ENABLED] ?: true + } + suspend fun setNotificationsEnabled(value: Boolean) { context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value } } @@ -159,6 +165,10 @@ class PreferencesManager(private val context: Context) { context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value } } + suspend fun setNotificationAvatarEnabled(value: Boolean) { + context.dataStore.edit { preferences -> preferences[NOTIFICATION_AVATAR_ENABLED] = value } + } + // ═════════════════════════════════════════════════════════════ // 💬 CHAT SETTINGS // ═════════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 9331692..3c6a07d 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,15 +17,16 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Протокол и вложения - - Обновлен Stream под новый серверный формат сериализации - - Добавлена поддержка transportServer/transportTag во вложениях - - Исправлена совместимость шифрования вложений Android -> Desktop - - Улучшена обработка call-аттачментов и рендер карточек звонков + Звонки + - Полноэкранный входящий звонок с Accept/Decline даже когда приложение свёрнуто или убито + - Исправлен сброс PeerConnection — больше нет зависания ~30 сек между звонками + - Защита от фантомных звонков при принятии на другом устройстве + + E2EE + - Улучшена диагностика шифрования звонков Push-уведомления - - Пуши теперь учитывают mute-чаты корректно - - Заголовок уведомления берет имя отправителя из payload сервера + - Поддержка tokenType и deviceId для новых серверов """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt index d78e1e6..8a89d2d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt @@ -9,13 +9,26 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon import android.os.Build import android.os.IBinder +import android.util.Base64 import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat import com.rosetta.messenger.MainActivity import com.rosetta.messenger.R +import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.utils.AvatarFileManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** * Keeps call alive while app goes to background. @@ -35,38 +48,72 @@ class CallForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val action = intent?.action ?: ACTION_SYNC CallManager.initialize(applicationContext) + notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}") when (action) { ACTION_STOP -> { + notifLog("ACTION_STOP → stopSelf") stopForegroundCompat() stopSelf() return START_NOT_STICKY } ACTION_END -> { + notifLog("ACTION_END → endCall") CallManager.endCall() stopForegroundCompat() stopSelf() return START_NOT_STICKY } ACTION_DECLINE -> { - CallManager.declineIncomingCall() + val phase = CallManager.state.value.phase + notifLog("ACTION_DECLINE phase=$phase") + if (phase == CallPhase.INCOMING) { + CallManager.declineIncomingCall() + } else { + // Если звонок уже не в INCOMING (CONNECTING/ACTIVE) — endCall + CallManager.endCall() + } stopForegroundCompat() stopSelf() return START_NOT_STICKY } ACTION_ACCEPT -> { - val result = CallManager.acceptIncomingCall() - if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) { - openCallUi() - } else { - Log.w(TAG, "Accept action ignored: $result") + notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}") + // Если push пришёл раньше WebSocket — CallManager ещё в IDLE. + // Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket. + CoroutineScope(Dispatchers.Main).launch { + var accepted = false + for (i in 1..50) { // 50 * 100ms = 5 sec + val phase = CallManager.state.value.phase + if (phase == CallPhase.INCOMING) { + val result = CallManager.acceptIncomingCall() + notifLog("ACTION_ACCEPT attempt #$i result=$result") + if (result == CallActionResult.STARTED) { + openCallUi() + notifLog("ACTION_ACCEPT → openCallUi()") + accepted = true + } + break + } else if (phase != CallPhase.IDLE) { + notifLog("ACTION_ACCEPT phase=$phase (not INCOMING/IDLE), opening UI") + openCallUi() + accepted = true + break + } + delay(100) + } + if (!accepted) { + notifLog("ACTION_ACCEPT: timed out waiting for INCOMING, phase=${CallManager.state.value.phase}") + } } } else -> Unit } val snapshot = extractSnapshot(intent) + notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}") if (snapshot.phase == CallPhase.IDLE) { + notifLog("phase=IDLE → stopSelf") stopForegroundCompat() stopSelf() return START_NOT_STICKY @@ -74,7 +121,18 @@ class CallForegroundService : Service() { ensureNotificationChannel() val notification = buildNotification(snapshot) + val hasFullScreen = snapshot.phase == CallPhase.INCOMING + notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground") startForegroundCompat(notification, snapshot.phase) + notifLog("startForeground OK, phase=${snapshot.phase}") + + // Проверяем canUseFullScreenIntent на Android 14+ + if (Build.VERSION.SDK_INT >= 34) { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + val canFsi = nm.canUseFullScreenIntent() + notifLog("Android 14+: canUseFullScreenIntent=$canFsi") + } + return START_STICKY } @@ -110,29 +168,40 @@ class CallForegroundService : Service() { private fun ensureNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val existing = manager.getNotificationChannel(CHANNEL_ID) - if (existing != null) return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return val channel = NotificationChannel( CHANNEL_ID, "Calls", - NotificationManager.IMPORTANCE_HIGH + NotificationManager.IMPORTANCE_MAX ).apply { - description = "Ongoing call controls" + description = "Incoming and ongoing calls" lockscreenVisibility = Notification.VISIBILITY_PUBLIC setShowBadge(false) + enableVibration(true) + vibrationPattern = longArrayOf(0, 1000, 500, 1000) + setBypassDnd(true) } manager.createNotificationChannel(channel) } private fun buildNotification(snapshot: Snapshot): Notification { + // При INCOMING — нажатие открывает IncomingCallActivity (полноэкранный звонок) + // При остальных фазах — открывает MainActivity + val contentActivity = if (snapshot.phase == CallPhase.INCOMING) { + com.rosetta.messenger.IncomingCallActivity::class.java + } else { + MainActivity::class.java + } val openAppPendingIntent = PendingIntent.getActivity( this, REQUEST_OPEN_APP, - Intent(this, MainActivity::class.java).apply { + Intent(this, contentActivity).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) + if (contentActivity == MainActivity::class.java) { + putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) + } }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -155,6 +224,18 @@ class CallForegroundService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // fullScreenIntent открывает лёгкую IncomingCallActivity поверх lock screen + val fullScreenPendingIntent = if (snapshot.phase == CallPhase.INCOMING) { + PendingIntent.getActivity( + this, + REQUEST_FULL_SCREEN, + Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else null + val defaultStatus = when (snapshot.phase) { CallPhase.INCOMING -> "Incoming call" @@ -164,9 +245,14 @@ class CallForegroundService : Service() { CallPhase.IDLE -> "Call ended" } val contentText = snapshot.statusText.ifBlank { defaultStatus } + val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build() + val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true) + if (avatarBitmap != null) { + personBuilder.setIcon(Icon.createWithBitmap(avatarBitmap)) + } + val person = personBuilder.build() val style = if (snapshot.phase == CallPhase.INCOMING) { Notification.CallStyle.forIncomingCall( @@ -188,6 +274,11 @@ class CallForegroundService : Service() { .setVisibility(Notification.VISIBILITY_PUBLIC) .setStyle(style) .setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + .apply { + if (fullScreenPendingIntent != null) { + setFullScreenIntent(fullScreenPendingIntent, true) + } + } .apply { if (snapshot.phase == CallPhase.ACTIVE) { setUsesChronometer(true) @@ -200,6 +291,7 @@ class CallForegroundService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(snapshot.displayName) + .apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) } .setContentText(contentText) .setContentIntent(openAppPendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -207,6 +299,11 @@ class CallForegroundService : Service() { .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOnlyAlertOnce(true) .setOngoing(true) + .apply { + if (fullScreenPendingIntent != null) { + setFullScreenIntent(fullScreenPendingIntent, true) + } + } .apply { if (snapshot.phase == CallPhase.INCOMING) { addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent) @@ -251,8 +348,10 @@ class CallForegroundService : Service() { private fun startForegroundTyped(notification: Notification, type: Int): Boolean { return try { startForeground(NOTIFICATION_ID, notification, type) + notifLog("startForeground OK type=$type") true } catch (error: Throwable) { + notifLog("startForeground FAILED type=$type: ${error.message}") Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}") false } @@ -261,8 +360,10 @@ class CallForegroundService : Service() { private fun startForegroundUntyped(notification: Notification): Boolean { return try { startForeground(NOTIFICATION_ID, notification) + notifLog("startForeground (untyped) OK") true } catch (error: Throwable) { + notifLog("startForeground (untyped) FAILED: ${error.message}") Log.w(TAG, "Untyped startForeground failed: ${error.message}") false } @@ -285,6 +386,8 @@ class CallForegroundService : Service() { private const val REQUEST_END_CALL = 9012 private const val REQUEST_DECLINE_CALL = 9013 private const val REQUEST_ACCEPT_CALL = 9014 + private const val REQUEST_FULL_SCREEN = 9015 + private const val NOTIF_LOG_FILE = "call_notification_log.txt" private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC" private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END" @@ -329,13 +432,82 @@ class CallForegroundService : Service() { } } + private fun loadAvatarBitmap(publicKey: String): Bitmap? { + if (publicKey.isBlank()) return null + // Проверяем настройку + val avatarEnabled = runCatching { + runBlocking(Dispatchers.IO) { + com.rosetta.messenger.data.PreferencesManager(applicationContext) + .notificationAvatarEnabled.first() + } + }.getOrDefault(true) + if (!avatarEnabled) return null + return runCatching { + val db = RosettaDatabase.getDatabase(applicationContext) + val entity = runBlocking(Dispatchers.IO) { + db.avatarDao().getLatestAvatarByKeys(listOf(publicKey)) + } ?: return null + val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar) + ?: return null + val bytes = Base64.decode(base64, Base64.DEFAULT) + val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + toCircleBitmap(original) + }.getOrNull() + } + + private fun toCircleBitmap(source: Bitmap): Bitmap { + val size = minOf(source.width, source.height) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(output) + val paint = android.graphics.Paint().apply { isAntiAlias = true } + val rect = android.graphics.Rect(0, 0, size, size) + canvas.drawARGB(0, 0, 0, 0) + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(source, rect, rect, paint) + return output + } + private fun openCallUi() { + notifLog("openCallUi → MainActivity") val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) } runCatching { startActivity(intent) } - .onFailure { error -> Log.w(TAG, "Failed to open call UI: ${error.message}") } + .onSuccess { notifLog("openCallUi → started OK") } + .onFailure { error -> + notifLog("openCallUi FAILED: ${error.message}") + Log.w(TAG, "Failed to open call UI: ${error.message}") + } + } + + private fun openIncomingCallUi() { + notifLog("openIncomingCallUi → IncomingCallActivity") + val intent = + Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + runCatching { startActivity(intent) } + .onSuccess { notifLog("openIncomingCallUi → started OK") } + .onFailure { error -> + notifLog("openIncomingCallUi FAILED: ${error.message}") + Log.w(TAG, "Failed to open incoming call UI: ${error.message}") + } + } + + /** Пишет лог в crash_reports/call_notification_log.txt — виден через rosettadev1 */ + private fun notifLog(msg: String) { + Log.d(TAG, msg) + try { + val dir = java.io.File(applicationContext.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, NOTIF_LOG_FILE) + // Ограничиваем размер файла — перезаписываем если больше 100KB + if (f.exists() && f.length() > 100_000) f.writeText("") + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + f.appendText("$ts $msg\n") + } catch (_: Throwable) {} } } diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index 31eda57..5c43352 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -187,6 +187,49 @@ object CallManager { ownPublicKey = publicKey.trim() } + /** + * Вызывается из FCM push когда приходит type=call. + * Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала. + * Если WebSocket CALL придёт позже — дедупликация его отбросит. + */ + fun setIncomingFromPush(peerPublicKey: String, peerTitle: String) { + val peer = peerPublicKey.trim() + if (peer.isBlank()) return + // Уже в звонке — не перебиваем + if (_state.value.phase != CallPhase.IDLE) { + breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}") + return + } + breadcrumb("setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle") + beginCallSession("incoming-push:${peer.take(8)}") + role = CallRole.CALLEE + resetRtcObjects() + val cachedInfo = ProtocolManager.getCachedUserInfo(peer) + val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() } + val username = cachedInfo?.username.orEmpty() + setPeer(peer, title, username) + updateState { + it.copy( + phase = CallPhase.INCOMING, + statusText = "Incoming call..." + ) + } + appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } + appContext?.let { ctx -> + CallForegroundService.syncWithCallState(ctx, _state.value) + } + resolvePeerIdentity(peer) + incomingRingTimeoutJob?.cancel() + incomingRingTimeoutJob = scope.launch { + delay(INCOMING_RING_TIMEOUT_MS) + val pending = _state.value + if (pending.phase == CallPhase.INCOMING && pending.peerPublicKey == peer) { + breadcrumb("setIncomingFromPush: timeout → auto-decline") + declineIncomingCall() + } + } + } + fun startOutgoingCall(user: SearchUser): CallActionResult { val targetKey = user.publicKey.trim() if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET @@ -322,25 +365,36 @@ object CallManager { when (packet.signalType) { SignalType.CALL -> { + val incomingPeer = packet.src.trim() + if (incomingPeer.isBlank()) return + + // Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя + if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) { + breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup") + resolvePeerIdentity(incomingPeer) + return + } + if (_state.value.phase != CallPhase.IDLE) { breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY") - val callerKey = packet.src.trim() - if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) { + if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) { ProtocolManager.sendCallSignal( signalType = SignalType.END_CALL_BECAUSE_BUSY, src = ownPublicKey, - dst = callerKey + dst = incomingPeer ) } return } - val incomingPeer = packet.src.trim() - if (incomingPeer.isBlank()) return beginCallSession("incoming:${incomingPeer.take(8)}") breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") role = CallRole.CALLEE resetRtcObjects() - setPeer(incomingPeer, "", "") + // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его + val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer) + val cachedTitle = cachedInfo?.title.orEmpty() + val cachedUsername = cachedInfo?.username.orEmpty() + setPeer(incomingPeer, cachedTitle, cachedUsername) updateState { it.copy( phase = CallPhase.INCOMING, @@ -348,6 +402,24 @@ object CallManager { ) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } + // Запускаем ForegroundService + IncomingCallActivity + appContext?.let { ctx -> + CallForegroundService.syncWithCallState(ctx, _state.value) + // Пробуем запустить IncomingCallActivity напрямую + try { + val activityIntent = android.content.Intent( + ctx, + com.rosetta.messenger.IncomingCallActivity::class.java + ).apply { + flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or + android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP + } + ctx.startActivity(activityIntent) + breadcrumb("IncomingCallActivity started from WebSocket OK") + } catch (e: Throwable) { + breadcrumb("IncomingCallActivity start FAILED: ${e.message} — relying on fullScreenIntent") + } + } resolvePeerIdentity(incomingPeer) incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob = @@ -869,6 +941,8 @@ object CallManager { incomingRingTimeoutJob = null setSpeakerphone(false) _state.value = CallUiState() + // Останавливаем ForegroundService + appContext?.let { CallForegroundService.stop(it) } } private fun resetRtcObjects() { @@ -1337,7 +1411,17 @@ object CallManager { } private fun updateState(reducer: (CallUiState) -> CallUiState) { + val old = _state.value _state.update(reducer) + val newState = _state.value + // Синхронизируем ForegroundService при смене фазы или имени + // Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop() + if (newState.phase != CallPhase.IDLE && + (newState.phase != old.phase || newState.displayName != old.displayName)) { + appContext?.let { ctx -> + CallForegroundService.syncWithCallState(ctx, newState) + } + } } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 3efd8b2..c062a48 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -5,19 +5,26 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build +import android.util.Base64 import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.rosetta.messenger.MainActivity import com.rosetta.messenger.R import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallPhase +import com.rosetta.messenger.network.CallUiState import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -41,8 +48,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { private const val TAG = "RosettaFCM" private const val CHANNEL_ID = "rosetta_messages" private const val CHANNEL_NAME = "Messages" - private const val CALL_CHANNEL_ID = "rosetta_calls_push" - private const val CALL_CHANNEL_NAME = "Calls" private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message" private const val PUSH_TYPE_GROUP_MESSAGE = "group_message" private const val PUSH_TYPE_CALL = "call" @@ -282,6 +287,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { createNotificationChannel() + // Резолвим имя и аватарку по publicKey + val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName + val avatarBitmap = loadAvatarBitmap(senderPublicKey) + val notifId = getNotificationIdForChat(senderPublicKey ?: "") // Intent для открытия чата @@ -302,12 +311,17 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(senderName) + .setContentTitle(resolvedName) .setContentText(messagePreview) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) + .apply { + if (avatarBitmap != null) { + setLargeIcon(avatarBitmap) + } + } .build() val notificationManager = @@ -336,6 +350,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { createNotificationChannel() + // Резолвим имя и аватарку по publicKey + val resolvedTitle = if (senderKey.isNotEmpty()) resolveNameForKey(senderKey) ?: title else title + val avatarBitmap = if (senderKey.isNotEmpty()) loadAvatarBitmap(senderKey) else null + // Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление val notifId = if (senderKey.isNotEmpty()) { getNotificationIdForChat(senderKey) @@ -359,11 +377,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) + .setContentTitle(resolvedTitle) .setContentText(body) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(pendingIntent) + .apply { + if (avatarBitmap != null) { + setLargeIcon(avatarBitmap) + } + } .build() val notificationManager = @@ -371,61 +394,69 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { notificationManager.notify(notifId, notification) } - /** Супер push входящего звонка: пробуждаем протокол и показываем call notification */ + /** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */ private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) { + pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title") wakeProtocolFromPush("call") - if (isAppInForeground || !areNotificationsEnabled()) return + if (!areNotificationsEnabled()) { + pushCallLog("SKIP: notifications disabled") + return + } val normalizedDialog = dialogKey.trim() - if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return - if (CallManager.state.value.phase != CallPhase.IDLE) return + if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) { + pushCallLog("SKIP: dialog muted") + return + } + val currentPhase = CallManager.state.value.phase + if (currentPhase != CallPhase.IDLE) { + pushCallLog("SKIP: phase=$currentPhase (not IDLE)") + return + } val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}" val now = System.currentTimeMillis() val lastTs = lastNotifTimestamps[dedupKey] - if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return + if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { + pushCallLog("SKIP: dedup blocked (delta=${now - lastTs}ms)") + return + } lastNotifTimestamps[dedupKey] = now - createCallNotificationChannel() + val resolvedName = resolveNameForKey(normalizedDialog) ?: title + pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush") - val notifId = - if (normalizedDialog.isNotEmpty()) { - getNotificationIdForChat(normalizedDialog) - } else { - ("call:$title:$body").hashCode() and 0x7FFFFFFF - } + // Сразу ставим CallManager в INCOMING — не ждём WebSocket + CallManager.setIncomingFromPush(normalizedDialog, resolvedName) + pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}") - val openIntent = - Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("open_chat", normalizedDialog) - putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) - } - val pendingIntent = - PendingIntent.getActivity( - this, - notifId, - openIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + // Пробуем запустить IncomingCallActivity напрямую из FCM + // На Android 10+ может быть заблокировано — тогда fullScreenIntent на нотификации сработает + try { + val activityIntent = android.content.Intent( + applicationContext, + com.rosetta.messenger.IncomingCallActivity::class.java + ).apply { + flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or + android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP + } + applicationContext.startActivity(activityIntent) + pushCallLog("IncomingCallActivity started from FCM OK") + } catch (e: Throwable) { + pushCallLog("IncomingCallActivity start from FCM FAILED: ${e.message} — relying on fullScreenIntent") + } + } - val notification = - NotificationCompat.Builder(this, CALL_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title.ifBlank { "Incoming call" }) - .setContentText(body.ifBlank { "Incoming call" }) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setFullScreenIntent(pendingIntent, true) - .build() - - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(notifId, notification) + private fun pushCallLog(msg: String) { + Log.d(TAG, msg) + try { + val dir = java.io.File(applicationContext.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, "call_notification_log.txt") + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + f.appendText("$ts [FCM] $msg\n") + } catch (_: Throwable) {} } /** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */ @@ -463,26 +494,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } } - /** Отдельный канал для входящих звонков */ - private fun createCallNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = - NotificationChannel( - CALL_CHANNEL_ID, - CALL_CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH - ) - .apply { - description = "Incoming call notifications" - enableVibration(true) - } - - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - } - /** Сохранить FCM токен в SharedPreferences */ private fun saveFcmToken(token: String) { val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) @@ -505,6 +516,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { return null } + private fun isAvatarInNotificationsEnabled(): Boolean { + return runCatching { + runBlocking(Dispatchers.IO) { + PreferencesManager(applicationContext).notificationAvatarEnabled.first() + } + }.getOrDefault(true) + } + /** Проверка: замьючен ли диалог для текущего аккаунта */ private fun isDialogMuted(senderPublicKey: String): Boolean { if (senderPublicKey.isBlank()) return false @@ -519,4 +538,56 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } }.getOrDefault(false) } + + /** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */ + private fun resolveNameForKey(publicKey: String?): String? { + if (publicKey.isNullOrBlank()) return null + // 1. In-memory cache + ProtocolManager.getCachedUserName(publicKey)?.let { return it } + // 2. DB dialogs table + return runCatching { + val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() + if (account.isBlank()) return null + val db = RosettaDatabase.getDatabase(applicationContext) + val dialog = runBlocking(Dispatchers.IO) { + db.dialogDao().getDialog(account, publicKey) + } + dialog?.opponentTitle?.takeIf { it.isNotBlank() } + ?: dialog?.opponentUsername?.takeIf { it.isNotBlank() } + }.getOrNull() + } + + /** Получить аватарку как круглый Bitmap для notification по publicKey */ + private fun loadAvatarBitmap(publicKey: String?): Bitmap? { + if (publicKey.isNullOrBlank()) return null + // Проверяем настройку + if (!isAvatarInNotificationsEnabled()) return null + return runCatching { + val db = RosettaDatabase.getDatabase(applicationContext) + val entity = runBlocking(Dispatchers.IO) { + db.avatarDao().getLatestAvatarByKeys(listOf(publicKey)) + } ?: return null + val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar) + ?: return null + val bytes = Base64.decode(base64, Base64.DEFAULT) + val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + // Делаем круглый bitmap для notification + toCircleBitmap(original) + }.getOrNull() + } + + private fun toCircleBitmap(source: Bitmap): Bitmap { + val size = minOf(source.width, source.height) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(output) + val paint = android.graphics.Paint().apply { + isAntiAlias = true + } + val rect = android.graphics.Rect(0, 0, size, size) + canvas.drawARGB(0, 0, 0, 0) + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(source, rect, rect, paint) + return output + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 8c097b3..dd32e27 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -94,8 +94,11 @@ import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.CallManager +import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.components.* @@ -319,7 +322,8 @@ fun ChatDetailScreen( chatWallpaperId: String = "", avatarRepository: AvatarRepository? = null, onImageViewerChanged: (Boolean) -> Unit = {}, - isCallActive: Boolean = false + isCallActive: Boolean = false, + onOpenCallOverlay: () -> Unit = {} ) { val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val context = LocalContext.current @@ -2302,6 +2306,19 @@ fun ChatDetailScreen( } ) } + // Баннер активного звонка (как в чат-листе) + val callUiState by CallManager.state.collectAsState() + val showCallBanner = callUiState.isVisible && + callUiState.phase != CallPhase.INCOMING + if (showCallBanner) { + CallTopBanner( + state = callUiState, + onOpenCall = onOpenCallOverlay, + isSticky = true, + isDarkTheme = isDarkTheme, + avatarRepository = avatarRepository + ) + } } // Закрытие Column topBar }, containerColor = backgroundColor, // Фон всего чата diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 64ea794..ebe2a15 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -845,6 +845,7 @@ fun ProfileScreen( run { val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true) + val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true) val scope = rememberCoroutineScope() TelegramToggleItem( @@ -858,6 +859,18 @@ fun ProfileScreen( }, isDarkTheme = isDarkTheme ) + + TelegramToggleItem( + icon = TelegramIcons.Photos, + title = "Avatars in Notifications", + isEnabled = avatarInNotifications, + onToggle = { + scope.launch { + preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications) + } + }, + isDarkTheme = isDarkTheme + ) } Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index eaecbff..1e46910 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -582,7 +582,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) { painter = painterResource(id = wallpaperResId), contentDescription = "Chat wallpaper preview", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.FillBounds + contentScale = ContentScale.Crop ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt index 471905e..8fc74e6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt @@ -21,101 +21,45 @@ object ThemeWallpapers { val all: List = listOf( ThemeWallpaper( - id = "back_3", - name = "Wallpaper 1", - preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_1", - drawableRes = R.drawable.wallpaper_back_3 - ), - ThemeWallpaper( - id = "back_4", - name = "Wallpaper 2", + id = "light_01", + name = "Light 1", preferredTheme = WallpaperTheme.LIGHT, pairGroup = "pair_1", - drawableRes = R.drawable.wallpaper_back_4 + drawableRes = R.drawable.wallpaper_light_01 ), ThemeWallpaper( - id = "back_5", - name = "Wallpaper 3", - preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_2", - drawableRes = R.drawable.wallpaper_back_5 - ), - ThemeWallpaper( - id = "back_6", - name = "Wallpaper 4", + id = "light_02", + name = "Light 2", preferredTheme = WallpaperTheme.LIGHT, pairGroup = "pair_2", - drawableRes = R.drawable.wallpaper_back_6 + drawableRes = R.drawable.wallpaper_light_02 ), ThemeWallpaper( - id = "back_7", - name = "Wallpaper 5", - preferredTheme = WallpaperTheme.LIGHT, - pairGroup = "pair_2", - drawableRes = R.drawable.wallpaper_back_7 - ), - ThemeWallpaper( - id = "back_8", - name = "Wallpaper 6", + id = "light_03", + name = "Light 3", preferredTheme = WallpaperTheme.LIGHT, pairGroup = "pair_3", - drawableRes = R.drawable.wallpaper_back_8 - ), - ThemeWallpaper( - id = "back_9", - name = "Wallpaper 7", - preferredTheme = WallpaperTheme.LIGHT, - pairGroup = "pair_1", - drawableRes = R.drawable.wallpaper_back_9 - ), - ThemeWallpaper( - id = "back_10", - name = "Wallpaper 8", - preferredTheme = WallpaperTheme.LIGHT, - pairGroup = "pair_4", - drawableRes = R.drawable.wallpaper_back_10 - ), - ThemeWallpaper( - id = "back_11", - name = "Wallpaper 9", - preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_3", - drawableRes = R.drawable.wallpaper_back_11 - ), - ThemeWallpaper( - id = "back_1", - name = "Wallpaper 10", - preferredTheme = WallpaperTheme.LIGHT, - pairGroup = "pair_3", - drawableRes = R.drawable.wallpaper_back_1 - ), - ThemeWallpaper( - id = "back_2", - name = "Wallpaper 11", - preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_4", - drawableRes = R.drawable.wallpaper_back_2 + drawableRes = R.drawable.wallpaper_light_03 ), ThemeWallpaper( id = "dark_01", name = "Dark 1", preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_5", + pairGroup = "pair_1", drawableRes = R.drawable.wallpaper_dark_01 ), ThemeWallpaper( id = "dark_02", name = "Dark 2", preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_5", + pairGroup = "pair_2", drawableRes = R.drawable.wallpaper_dark_02 ), ThemeWallpaper( id = "dark_03", name = "Dark 3", preferredTheme = WallpaperTheme.DARK, - pairGroup = "pair_6", + pairGroup = "pair_3", drawableRes = R.drawable.wallpaper_dark_03 ) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt index e88589b..8f68fd5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt @@ -11,7 +11,9 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.* import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density import androidx.core.view.WindowCompat import com.rosetta.messenger.ui.utils.NavigationModeUtils import kotlinx.coroutines.delay @@ -86,7 +88,17 @@ fun RosettaAndroidTheme( } } - CompositionLocalProvider(LocalRosettaIsDarkTheme provides darkTheme) { + // Ограничиваем fontScale чтобы вёрстка не ломалась на телефонах с огромным масштабом текста + val currentDensity = LocalDensity.current + val cappedDensity = Density( + density = currentDensity.density, + fontScale = currentDensity.fontScale.coerceAtMost(1.3f) + ) + + CompositionLocalProvider( + LocalRosettaIsDarkTheme provides darkTheme, + LocalDensity provides cappedDensity + ) { MaterialTheme( colorScheme = colorScheme, typography = Typography, diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_1.png b/app/src/main/res/drawable-nodpi/wallpaper_back_1.png deleted file mode 100644 index b490b49..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_1.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_10.png b/app/src/main/res/drawable-nodpi/wallpaper_back_10.png deleted file mode 100644 index 482c8b1..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_10.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_11.png b/app/src/main/res/drawable-nodpi/wallpaper_back_11.png deleted file mode 100644 index 0518932..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_11.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_2.png b/app/src/main/res/drawable-nodpi/wallpaper_back_2.png deleted file mode 100644 index 4946de6..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_2.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_3.png b/app/src/main/res/drawable-nodpi/wallpaper_back_3.png deleted file mode 100644 index b490b49..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_3.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_4.png b/app/src/main/res/drawable-nodpi/wallpaper_back_4.png deleted file mode 100644 index b8cd264..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_4.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_5.png b/app/src/main/res/drawable-nodpi/wallpaper_back_5.png deleted file mode 100644 index 3ec7b78..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_5.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_6.png b/app/src/main/res/drawable-nodpi/wallpaper_back_6.png deleted file mode 100644 index ac9297f..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_6.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_7.png b/app/src/main/res/drawable-nodpi/wallpaper_back_7.png deleted file mode 100644 index 4df23a3..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_7.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_8.png b/app/src/main/res/drawable-nodpi/wallpaper_back_8.png deleted file mode 100644 index 9994940..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_8.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_9.png b/app/src/main/res/drawable-nodpi/wallpaper_back_9.png deleted file mode 100644 index 1490358..0000000 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_back_9.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_dark_04.png b/app/src/main/res/drawable-nodpi/wallpaper_light_01.png similarity index 100% rename from app/src/main/res/drawable-nodpi/wallpaper_dark_04.png rename to app/src/main/res/drawable-nodpi/wallpaper_light_01.png diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_02.png b/app/src/main/res/drawable-nodpi/wallpaper_light_02.png new file mode 100644 index 0000000..dd3c8be Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_light_02.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png new file mode 100644 index 0000000..c69cdca Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png differ