feat: improve media viewer and push delivery stability
Some checks failed
Android CI / android (push) Failing after 11m0s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

- add unified Android media viewer with swipe navigation, pinch-to-zoom and swipe-to-dismiss\n- move circle videos out of media gallery and surface them in voice/chat info flows\n- align web chat info handling for circle videos and media viewer exclusions\n- stabilize realtime and tablet chat shell updates already staged in this batch\n- fix Celery push delivery loop handling so FCM jobs can read tokens reliably in worker processes
This commit is contained in:
2026-04-05 14:06:36 +03:00
parent b40dea18f1
commit d2e0969fd5
16 changed files with 1916 additions and 765 deletions

View File

@@ -107,6 +107,8 @@ dependencies {
implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1") implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1") implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.media3:media3-effect:1.4.1")
implementation("androidx.media3:media3-transformer:1.4.1")
implementation("androidx.camera:camera-core:1.4.2") implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2") implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2") implementation("androidx.camera:camera-lifecycle:1.4.2")

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application <application
android:name=".MessengerApplication" android:name=".MessengerApplication"

View File

@@ -20,6 +20,7 @@ import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme import ru.daemonlord.messenger.ui.theme.MessengerTheme
@@ -36,6 +37,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var notificationDispatcher: NotificationDispatcher lateinit var notificationDispatcher: NotificationDispatcher
@Inject
lateinit var handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase
private var pendingInviteToken by mutableStateOf<String?>(null) private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null) private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null) private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -70,6 +74,7 @@ class MainActivity : AppCompatActivity() {
pendingNotificationChatId = notificationPayload?.first pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications) notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
handleRealtimeEventsUseCase.start()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MessengerTheme { MessengerTheme {

View File

@@ -1,10 +1,14 @@
package ru.daemonlord.messenger.core.notifications package ru.daemonlord.messenger.core.notifications
import android.Manifest
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.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import ru.daemonlord.messenger.MainActivity import ru.daemonlord.messenger.MainActivity
import javax.inject.Inject import javax.inject.Inject
@@ -73,7 +77,7 @@ class NotificationDispatcher @Inject constructor(
.build() .build()
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
manager.notify(chatNotificationId(payload.chatId), notification) manager.notifySafely(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager) showSummaryNotification(manager)
} }
@@ -82,14 +86,14 @@ class NotificationDispatcher @Inject constructor(
synchronized(chatStates) { synchronized(chatStates) {
chatStates.remove(chatId) chatStates.remove(chatId)
} }
manager.cancel(chatNotificationId(chatId)) manager.cancelSafely(chatNotificationId(chatId))
showSummaryNotification(manager) showSummaryNotification(manager)
} }
private fun showSummaryNotification(manager: NotificationManagerCompat) { private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() } val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) { if (snapshot.isEmpty()) {
manager.cancel(SUMMARY_NOTIFICATION_ID) manager.cancelSafely(SUMMARY_NOTIFICATION_ID)
return return
} }
val totalUnread = snapshot.sumOf { it.unreadCount } val totalUnread = snapshot.sumOf { it.unreadCount }
@@ -114,7 +118,24 @@ class NotificationDispatcher @Inject constructor(
.setGroupSummary(true) .setGroupSummary(true)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
manager.notify(SUMMARY_NOTIFICATION_ID, summary) manager.notifySafely(SUMMARY_NOTIFICATION_ID, summary)
}
private fun NotificationManagerCompat.notifySafely(id: Int, notification: android.app.Notification) {
if (!canPostNotifications()) return
runCatching { notify(id, notification) }
}
private fun NotificationManagerCompat.cancelSafely(id: Int) {
runCatching { cancel(id) }
}
private fun canPostNotifications(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
} }
private fun chatNotificationId(chatId: Long): Int { private fun chatNotificationId(chatId: Long): Int {

View File

@@ -2,8 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.ImageDecoder
import android.graphics.Movie import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -166,14 +166,8 @@ class NetworkMediaRepository @Inject constructor(
fileName: String, fileName: String,
bytes: ByteArray, bytes: ByteArray,
): UploadPayload? { ): UploadPayload? {
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null val bitmap = decodeGifFirstFrame(bytes) ?: return null
val width = movie.width().coerceAtLeast(1)
val height = movie.height().coerceAtLeast(1)
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
return try { return try {
val canvas = Canvas(bitmap)
movie.setTime(0)
movie.draw(canvas, 0f, 0f)
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
if (!compressed) return null if (!compressed) return null
@@ -189,4 +183,17 @@ class NetworkMediaRepository @Inject constructor(
bitmap.recycle() bitmap.recycle()
} }
} }
private fun decodeGifFirstFrame(bytes: ByteArray): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val decoded = runCatching {
val source = ImageDecoder.createSource(java.nio.ByteBuffer.wrap(bytes))
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
}.getOrNull()
if (decoded != null) return decoded
}
return runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
} }

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.di package ru.daemonlord.messenger.di
import android.content.Context import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -15,10 +16,12 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@UnstableApi
object MediaCacheModule { object MediaCacheModule {
@Provides @Provides
@Singleton @Singleton
@UnstableApi
fun provideMediaCache( fun provideMediaCache(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): Cache { ): Cache {
@@ -34,4 +37,3 @@ object MediaCacheModule {
) )
} }
} }

View File

@@ -14,6 +14,7 @@ import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val messageRepository: MessageRepository,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository, private val tokenRepository: TokenRepository,
@@ -50,6 +52,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
is RealtimeEvent.ReceiveMessage -> { is RealtimeEvent.ReceiveMessage -> {
val activeChatId = activeChatTracker.activeChatId.value val activeChatId = activeChatTracker.activeChatId.value
val lastMessagePreview = event.text?.takeIf { it.isNotBlank() }
?: fallbackMessagePreview(event.type)
messageDao.upsertMessages( messageDao.upsertMessages(
listOf( listOf(
MessageEntity( MessageEntity(
@@ -75,16 +79,18 @@ class HandleRealtimeEventsUseCase @Inject constructor(
) )
chatDao.updateLastMessage( chatDao.updateLastMessage(
chatId = event.chatId, chatId = event.chatId,
lastMessageText = event.text, lastMessageText = lastMessagePreview,
lastMessageType = event.type, lastMessageType = event.type,
lastMessageCreatedAt = event.createdAt, lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt, updatedSortAt = event.createdAt,
) )
if (activeChatId == event.chatId) { if (activeChatId == event.chatId) {
chatDao.markChatRead(chatId = event.chatId) chatDao.markChatRead(chatId = event.chatId)
messageRepository.syncRecentMessages(chatId = event.chatId)
} else { } else {
chatDao.incrementUnread(chatId = event.chatId) chatDao.incrementUnread(chatId = event.chatId)
} }
chatRepository.refreshChat(chatId = event.chatId)
val activeUserId = tokenRepository.getActiveUserId() val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId -> val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts() tokenRepository.getAccounts()
@@ -142,11 +148,15 @@ class HandleRealtimeEventsUseCase @Inject constructor(
) )
chatDao.updateLastMessage( chatDao.updateLastMessage(
chatId = event.chatId, chatId = event.chatId,
lastMessageText = event.text, lastMessageText = event.text?.takeIf { it.isNotBlank() } ?: fallbackMessagePreview(event.type),
lastMessageType = event.type, lastMessageType = event.type,
lastMessageCreatedAt = event.updatedAt, lastMessageCreatedAt = event.updatedAt,
updatedSortAt = event.updatedAt, updatedSortAt = event.updatedAt,
) )
if (activeChatTracker.activeChatId.value == event.chatId) {
messageRepository.syncRecentMessages(chatId = event.chatId)
}
chatRepository.refreshChat(chatId = event.chatId)
} }
is RealtimeEvent.MessageDeleted -> { is RealtimeEvent.MessageDeleted -> {
@@ -156,6 +166,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} }
is RealtimeEvent.ChatUpdated -> { is RealtimeEvent.ChatUpdated -> {
chatRepository.refreshChat(chatId = event.chatId)
chatRepository.refreshChats(archived = false) chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true) chatRepository.refreshChats(archived = true)
} }
@@ -209,4 +220,16 @@ class HandleRealtimeEventsUseCase @Inject constructor(
collectionJob = null collectionJob = null
realtimeManager.disconnect() realtimeManager.disconnect()
} }
private fun fallbackMessagePreview(type: String?): String {
return when (type?.lowercase()) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
"circle_video" -> "Video message"
else -> "New message"
}
}
} }

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat.voice package ru.daemonlord.messenger.ui.chat.voice
import android.content.Context import android.content.Context
import android.os.Build
import android.media.MediaRecorder import android.media.MediaRecorder
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@@ -13,7 +14,7 @@ class VoiceRecorder(private val context: Context) {
fun start(): Boolean { fun start(): Boolean {
return runCatching { return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a") val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
val mediaRecorder = MediaRecorder().apply { val mediaRecorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -34,6 +35,15 @@ class VoiceRecorder(private val context: Context) {
} }
} }
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long { fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
if (startedAtMillis <= 0L) return 0L if (startedAtMillis <= 0L) return 0L
return (nowMillis - startedAtMillis).coerceAtLeast(0L) return (nowMillis - startedAtMillis).coerceAtLeast(0L)

View File

@@ -103,6 +103,7 @@ fun ChatListRoute(
onInviteTokenConsumed: () -> Unit, onInviteTokenConsumed: () -> Unit,
isMainBarVisible: Boolean, isMainBarVisible: Boolean,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
selectedChatId: Long? = null,
viewModel: ChatListViewModel = hiltViewModel(), viewModel: ChatListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -153,6 +154,7 @@ fun ChatListRoute(
onBanMember = viewModel::banMember, onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember, onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode, onToggleDayNightMode = viewModel::toggleDayNightMode,
selectedChatId = selectedChatId,
) )
} }
@@ -194,6 +196,7 @@ fun ChatListScreen(
onBanMember: (Long, Long) -> Unit, onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit, onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit, onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
selectedChatId: Long? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) } var managementExpanded by remember { mutableStateOf(false) }
@@ -534,6 +537,7 @@ fun ChatListScreen(
chat = chat, chat = chat,
isSelecting = selectedChatIds.isNotEmpty(), isSelecting = selectedChatIds.isNotEmpty(),
isSelected = selectedChatIds.contains(chat.id), isSelected = selectedChatIds.contains(chat.id),
isActive = selectedChatIds.isEmpty() && selectedChatId == chat.id,
onClick = { onClick = {
if (selectedChatIds.isNotEmpty()) { if (selectedChatIds.isNotEmpty()) {
selectedChatIds = if (selectedChatIds.contains(chat.id)) { selectedChatIds = if (selectedChatIds.contains(chat.id)) {
@@ -1392,12 +1396,20 @@ private fun ChatRow(
chat: ChatItem, chat: ChatItem,
isSelecting: Boolean, isSelecting: Boolean,
isSelected: Boolean, isSelected: Boolean,
isActive: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(
when {
isActive -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else -> Color.Transparent
},
)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,

View File

@@ -3,17 +3,21 @@ package ru.daemonlord.messenger.ui.navigation
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -28,6 +32,7 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -37,10 +42,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -99,16 +107,23 @@ fun MessengerNavHost(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val isTabletLandscape = remember(configuration.screenWidthDp, configuration.screenHeightDp) {
configuration.screenWidthDp >= 840 && configuration.screenWidthDp > configuration.screenHeightDp
}
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) {} ) {}
var isMainBarVisible by remember { mutableStateOf(true) } var isMainBarVisible by remember { mutableStateOf(true) }
var tabletSelectedChatId by rememberSaveable { mutableStateOf<Long?>(null) }
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val mainTabRoutes = remember { val mainTabRoutes = remember {
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile) setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
} }
val showMainBar = currentRoute in mainTabRoutes val isChatRoute = currentRoute?.startsWith("${Routes.Chat}/") == true
val showMainBar = (currentRoute in mainTabRoutes) &&
!(isTabletLandscape && (currentRoute == Routes.Chats || isChatRoute))
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -143,11 +158,21 @@ fun MessengerNavHost(
} }
if (uiState.isAuthenticated) { if (uiState.isAuthenticated) {
if (notificationChatId != null) { if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") { if (isTabletLandscape) {
popUpTo(navController.graph.findStartDestination().id) { tabletSelectedChatId = notificationChatId
inclusive = true navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
} else {
navController.navigate("${Routes.Chat}/$notificationChatId") {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
} }
launchSingleTop = true
} }
onNotificationConsumed() onNotificationConsumed()
} else { } else {
@@ -277,15 +302,25 @@ fun MessengerNavHost(
} }
composable(route = Routes.Chats) { composable(route = Routes.Chats) {
ChatListRoute( if (isTabletLandscape) {
inviteToken = inviteToken, TabletChatsRoute(
onInviteTokenConsumed = onInviteTokenConsumed, parentNavController = navController,
onOpenChat = { chatId -> selectedChatId = tabletSelectedChatId,
navController.navigate("${Routes.Chat}/$chatId") onSelectChat = { tabletSelectedChatId = it },
}, inviteToken = inviteToken,
isMainBarVisible = isMainBarVisible, onInviteTokenConsumed = onInviteTokenConsumed,
onMainBarVisibilityChanged = { isMainBarVisible = it }, )
) } else {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
},
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
}
} }
composable(route = Routes.Contacts) { composable(route = Routes.Contacts) {
@@ -323,9 +358,30 @@ fun MessengerNavHost(
navArgument("chatId") { type = NavType.LongType } navArgument("chatId") { type = NavType.LongType }
), ),
) { backStackEntry -> ) { backStackEntry ->
ChatRoute( val selectedChatId = backStackEntry.arguments?.getLong("chatId")
onBack = { navController.popBackStack() }, if (isTabletLandscape) {
) LaunchedEffect(selectedChatId) {
tabletSelectedChatId = selectedChatId
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
TabletChatsRoute(
parentNavController = navController,
selectedChatId = tabletSelectedChatId ?: selectedChatId,
onSelectChat = { tabletSelectedChatId = it },
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
)
} else {
ChatRoute(
onBack = { navController.popBackStack() },
)
}
} }
} }
@@ -355,14 +411,116 @@ fun MessengerNavHost(
} }
} }
@Composable
private fun TabletChatsRoute(
parentNavController: NavHostController,
selectedChatId: Long?,
onSelectChat: (Long?) -> Unit,
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
) {
val configuration = LocalConfiguration.current
val listPaneWidth = remember(configuration.screenWidthDp) {
(configuration.screenWidthDp * 0.29f).dp.coerceIn(320.dp, 420.dp)
}
BackHandler(enabled = selectedChatId != null) {
onSelectChat(null)
}
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Surface(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
) {
Box(modifier = Modifier.fillMaxSize()) {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
onSelectChat(chatId)
},
isMainBarVisible = true,
onMainBarVisibilityChanged = {},
selectedChatId = selectedChatId,
)
MainBottomBar(
currentRoute = Routes.Chats,
onNavigate = { route ->
if (route == Routes.Chats) {
parentNavController.navigate(Routes.Chats) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
} else {
parentNavController.navigate(route) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp),
)
}
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
) {
key(selectedChatId) {
val detailNavController = rememberNavController()
NavHost(
navController = detailNavController,
startDestination = if (selectedChatId == null) "empty" else "${Routes.Chat}/$selectedChatId",
) {
composable("empty") {
TabletChatPlaceholder()
}
composable(
route = "${Routes.Chat}/{chatId}",
arguments = listOf(navArgument("chatId") { type = NavType.LongType }),
) {
ChatRoute(
onBack = {
onSelectChat(null)
},
showBackButton = false,
)
}
}
}
}
}
}
@Composable @Composable
private fun MainBottomBar( private fun MainBottomBar(
currentRoute: String?, currentRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
) { ) {
Surface( Surface(
modifier = Modifier modifier = modifier
.padding(horizontal = 12.dp)
.fillMaxWidth(), .fillMaxWidth(),
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
@@ -454,3 +612,58 @@ private fun SessionCheckingScreen() {
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
@Composable
private fun TabletChatPlaceholder() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
tonalElevation = 2.dp,
shadowElevation = 10.dp,
modifier = Modifier.fillMaxWidth(0.56f),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
modifier = Modifier.padding(bottom = 18.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(18.dp),
)
}
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
}

View File

@@ -28,6 +28,9 @@
<string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string> <string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string>
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string> <string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string> <string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string>
<string name="tablet_chat_placeholder_title">Выберите чат</string>
<string name="tablet_chat_placeholder_body">Откройте диалог из левой колонки, и он появится здесь.</string>
<string name="tablet_chat_placeholder_hint">Открытый чат остаётся справа, а список слева всегда под рукой.</string>
<string name="filter_all">Все</string> <string name="filter_all">Все</string>
<string name="filter_people">Люди</string> <string name="filter_people">Люди</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Стикеры</string> <string name="chat_picker_tab_stickers">Стикеры</string>
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string> <string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
<string name="chat_playback_subtitle_audio">Аудио • %1$s</string> <string name="chat_playback_subtitle_audio">Аудио • %1$s</string>
<string name="chat_playback_subtitle_circle">Видеокружок • %1$s</string>
<string name="chat_user_fallback_with_id">Пользователь #%1$d</string> <string name="chat_user_fallback_with_id">Пользователь #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string> <string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string> <string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string> <string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
<string name="account_error_invalid_credentials">Неверные учетные данные.</string> <string name="account_error_invalid_credentials">Неверные учетные данные.</string>
<string name="account_error_unauthorized">Не авторизовано.</string> <string name="account_error_unauthorized">Не авторизовано.</string>
<string name="chat_audio_strip_video_note">Видеокружок</string>
</resources> </resources>

View File

@@ -68,6 +68,9 @@
<string name="chats_dialog_delete_selected_title">Delete selected chats</string> <string name="chats_dialog_delete_selected_title">Delete selected chats</string>
<string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string> <string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string>
<string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string> <string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string>
<string name="tablet_chat_placeholder_title">Choose a chat</string>
<string name="tablet_chat_placeholder_body">Select a conversation from the left column to open it here.</string>
<string name="tablet_chat_placeholder_hint">Your current chat stays open here while the list remains visible.</string>
<string name="common_cancel">Cancel</string> <string name="common_cancel">Cancel</string>
<string name="common_confirm">Confirm</string> <string name="common_confirm">Confirm</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Stickers</string> <string name="chat_picker_tab_stickers">Stickers</string>
<string name="chat_playback_subtitle_voice">Voice message • %1$s</string> <string name="chat_playback_subtitle_voice">Voice message • %1$s</string>
<string name="chat_playback_subtitle_audio">Audio • %1$s</string> <string name="chat_playback_subtitle_audio">Audio • %1$s</string>
<string name="chat_playback_subtitle_circle">Video note • %1$s</string>
<string name="chat_user_fallback_with_id">User #%1$d</string> <string name="chat_user_fallback_with_id">User #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string> <string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string> <string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string> <string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
<string name="account_error_invalid_credentials">Invalid credentials.</string> <string name="account_error_invalid_credentials">Invalid credentials.</string>
<string name="account_error_unauthorized">Unauthorized.</string> <string name="account_error_unauthorized">Unauthorized.</string>
<string name="chat_audio_strip_video_note">Video note</string>
</resources> </resources>

View File

@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
_firebase_app: firebase_admin.App | None = None _firebase_app: firebase_admin.App | None = None
_worker_loop: asyncio.AbstractEventLoop | None = None
def _get_worker_loop() -> asyncio.AbstractEventLoop:
global _worker_loop
if _worker_loop is None or _worker_loop.is_closed():
_worker_loop = asyncio.new_event_loop()
return _worker_loop
def _run_async(coro):
loop = _get_worker_loop()
return loop.run_until_complete(coro)
def _get_firebase_app() -> firebase_admin.App | None: def _get_firebase_app() -> firebase_admin.App | None:
@@ -63,7 +76,7 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id) logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id)
return return
tokens = asyncio.run(_load_tokens(user_id)) tokens = _run_async(_load_tokens(user_id))
if not tokens: if not tokens:
return return
@@ -83,9 +96,9 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
try: try:
messaging.send(message, app=app) messaging.send(message, app=app)
except messaging.UnregisteredError: except messaging.UnregisteredError:
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token)) _run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except messaging.SenderIdMismatchError: except messaging.SenderIdMismatchError:
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token)) _run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except Exception: except Exception:
logger.exception("FCM send failed for user=%s platform=%s", user_id, platform) logger.exception("FCM send failed for user=%s platform=%s", user_id, platform)

View File

@@ -104,9 +104,21 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const count = chat.members_count ?? members.length; const count = chat.members_count ?? members.length;
return count <= 1; return count <= 1;
}, [chat, isGroupLike, myRoleNormalized, members.length]); }, [chat, isGroupLike, myRoleNormalized, members.length]);
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]); const photoAttachments = useMemo(
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]); () => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id),
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]); [attachments]
);
const videoAttachments = useMemo(
() =>
attachments
.filter((item) => item.file_type.startsWith("video/") && item.message_type !== "circle_video")
.sort((a, b) => b.id - a.id),
[attachments]
);
const voiceAttachments = useMemo(
() => attachments.filter((item) => item.message_type === "voice" || item.message_type === "circle_video").sort((a, b) => b.id - a.id),
[attachments]
);
const audioAttachments = useMemo( const audioAttachments = useMemo(
() => () =>
attachments attachments
@@ -871,10 +883,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
.slice(0, 120) .slice(0, 120)
.map((item) => ( .map((item) => (
<button <button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900" className={`group relative aspect-square overflow-hidden border border-slate-700/70 bg-slate-900 ${item.message_type === "circle_video" ? "rounded-full" : "rounded-md"}`}
key={`media-item-${item.id}`} key={`media-item-${item.id}`}
onClick={() => { onClick={() => {
if (item.message_type === "circle_video") {
jumpToMessage(item.message_id);
return;
}
const mediaItems = [...photoAttachments, ...videoAttachments] const mediaItems = [...photoAttachments, ...videoAttachments]
.filter((it) => it.message_type !== "circle_video")
.sort((a, b) => b.id - a.id) .sort((a, b) => b.id - a.id)
.map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id })); .map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id }));
const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id); const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id);

View File

@@ -1100,6 +1100,7 @@ function renderMessageContent(
if (messageType === "image" || messageType === "video" || messageType === "circle_video") { if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
const mediaItems = attachments const mediaItems = attachments
.filter((item) => item.message_type !== "circle_video")
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")) .filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
.map((item) => ({ .map((item) => ({
url: item.file_url, url: item.file_url,
@@ -1113,13 +1114,14 @@ function renderMessageContent(
} }
if (mediaItems.length === 1) { if (mediaItems.length === 1) {
const item = mediaItems[0]; const item = mediaItems[0];
const isCircleVideo = messageType === "circle_video";
const blockViewerOpen = isStickerOrGifMedia(item.url); const blockViewerOpen = isStickerOrGifMedia(item.url);
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<button <button
className="relative block overflow-hidden rounded-xl bg-slate-950/30" className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => { onClick={() => {
if (blockViewerOpen) { if (blockViewerOpen || isCircleVideo) {
return; return;
} }
opts.onOpenMedia(item.url, item.type); opts.onOpenMedia(item.url, item.type);
@@ -1131,10 +1133,15 @@ function renderMessageContent(
type="button" type="button"
> >
{item.type === "image" ? ( {item.type === "image" ? (
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={item.url} /> <img
alt="attachment"
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
draggable={false}
src={item.url}
/>
) : ( ) : (
<> <>
<video className="max-h-80 rounded-xl" muted src={item.url} /> <video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span> <span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</> </>
)} )}
@@ -1410,6 +1417,7 @@ function collectMediaItems(
const attachments = attachmentsByMessage[message.id] ?? []; const attachments = attachmentsByMessage[message.id] ?? [];
for (const attachment of attachments) { for (const attachment of attachments) {
if (!attachment.file_url) continue; if (!attachment.file_url) continue;
if (attachment.message_type === "circle_video") continue;
if (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue; if (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue;
if (isStickerOrGifMedia(attachment.file_url)) continue; if (isStickerOrGifMedia(attachment.file_url)) continue;
const type = attachment.file_type.startsWith("image/") ? "image" : "video"; const type = attachment.file_type.startsWith("image/") ? "image" : "video";
@@ -1419,7 +1427,7 @@ function collectMediaItems(
items.push({ url: attachment.file_url, type }); items.push({ url: attachment.file_url, type });
} }
if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) { if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) {
if (message.type !== "image" && message.type !== "video" && message.type !== "circle_video") continue; if (message.type !== "image" && message.type !== "video") continue;
if (isStickerOrGifMedia(message.text)) continue; if (isStickerOrGifMedia(message.text)) continue;
const type = message.type === "image" ? "image" : "video"; const type = message.type === "image" ? "image" : "video";
const key = `${type}:${message.text}`; const key = `${type}:${message.text}`;