This commit is contained in:
Халимов Рустам
2026-04-15 02:40:52 +03:00
parent 9560a9235f
commit 8409c51842
16 changed files with 207 additions and 34 deletions

View File

@@ -13,9 +13,21 @@ import core.presentation.theme.ForkMessengerTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@javax.inject.Inject
lateinit var navigationManager: core.utils.NavigationManager
@javax.inject.Inject
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
signalrNotificationObserver.start()
intent.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
// Request notifications permission for Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
@@ -31,9 +43,16 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation()
AppNavigation(navigationManager = navigationManager)
}
}
}
}
override fun onNewIntent(intent: android.content.Intent?) {
super.onNewIntent(intent)
intent?.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
}
}

View File

@@ -0,0 +1,54 @@
package chats.data.remote.signalr
import android.content.Context
import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper
import core.security.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
fun start() {
if (isStarted) return
isStarted = true
signalrClient.events
.filterIsInstance<ChatEvent.NewMessage>()
.onEach { event ->
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message
if (message.senderId == currentUserId) return@onEach
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode()
)
}
.launchIn(scope)
}
}

View File

@@ -50,6 +50,7 @@ class ChatDetailViewModel @Inject constructor(
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
@@ -88,6 +89,7 @@ class ChatDetailViewModel @Inject constructor(
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
activeChatTracker.setChatId(chatId)
_state.update { it.copy(
messages = emptyList(),
@@ -248,6 +250,7 @@ class ChatDetailViewModel @Inject constructor(
signalrEventsJob?.cancel()
signalrEventsJob = null
currentChatId = null
activeChatTracker.setChatId(null)
_state.update { it.copy(messages = emptyList(), isLoading = false) }
}

View File

@@ -24,6 +24,7 @@ data class ChatListState(
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository,
private val authRepository: auth.domain.repository.AuthRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
@@ -33,6 +34,8 @@ class ChatListViewModel @Inject constructor(
val state: StateFlow<ChatListState> = _state.asStateFlow()
init {
updatePushToken()
val isStoriesEnabled = try {
serverConfig.getServerConfig().features.stories
} catch (e: Exception) {
@@ -50,6 +53,21 @@ class ChatListViewModel @Inject constructor(
observeSignalREvents()
}
private fun updatePushToken() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
try {
authRepository.updatePushToken(token)
} catch (e: Exception) {
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
}
}
}
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""

View File

@@ -0,0 +1,17 @@
package core.notifications.data
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Singleton
class ActiveChatTracker @Inject constructor() {
private val _currentChatId = MutableStateFlow<String?>(null)
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
fun setChatId(chatId: String?) {
_currentChatId.value = chatId
}
}

View File

@@ -40,46 +40,19 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
android.util.Log.d("FCM", "Message received: ${message.data}")
val title = message.notification?.title ?: message.data["title"]
val body = message.notification?.body ?: message.data["body"]
val type = message.data["type"] // "chat", "call", "story"
val chatId = message.data["chatId"]
val messageId = message.data["id"] ?: message.messageId
showNotification(title, body, type)
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode())
}
private fun showNotification(title: String?, body: String?, type: String?) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "fork_notifications_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Fork Messenger Notifications",
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(channel)
}
// Create intent to open MainActivity
val intent = Intent(this, com.knot.messenger.MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("type", type)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
NotificationHelper.showNotification(this, title, body, type)
}
override fun onDestroy() {

View File

@@ -0,0 +1,55 @@
package core.notifications.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
object NotificationHelper {
private const val CHANNEL_ID = "fork_notifications_channel"
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Fork Messenger Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Chat and system notifications"
}
notificationManager.createNotificationChannel(channel)
}
// Find our launcher activity via package manager to avoid static dependency on MainActivity
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("type", type)
putExtra("chatId", chatId)
} ?: return
val pendingIntent = PendingIntent.getActivity(
context, System.currentTimeMillis().toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentIntent(pendingIntent)
notificationManager.notify(finalNotificationId, notificationBuilder.build())
}
}

View File

@@ -0,0 +1,19 @@
package core.utils
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
sealed class NavEvent {
data class OpenChat(val chatId: String) : NavEvent()
}
@Singleton
class NavigationManager @Inject constructor() {
private val _events = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
val events = _events
fun navigateToChat(chatId: String) {
_events.tryEmit(NavEvent.OpenChat(chatId))
}
}

View File

@@ -51,12 +51,27 @@ sealed class Screen(val route: String) {
@Composable
fun AppNavigation(
navController: NavHostController = rememberNavController()
navController: NavHostController = rememberNavController(),
navigationManager: core.utils.NavigationManager? = null
) {
val authViewModel: AuthViewModel = hiltViewModel()
val authState by authViewModel.state.collectAsState()
val startDestination = if (authState.isAuthenticated) Screen.ChatList.route else Screen.Login.route
androidx.compose.runtime.LaunchedEffect(navigationManager) {
navigationManager?.events?.collect { event ->
when (event) {
is core.utils.NavEvent.OpenChat -> {
if (authState.isAuthenticated) {
navController.navigate(Screen.ChatDetail.createRoute(event.chatId, "Chat")) {
launchSingleTop = true
}
}
}
}
}
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route