diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index a59bf57..9ea1f9b 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 2cc77ac..a96186d 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 65e22f6..4f988c9 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 2bbac3b..7cff6db 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index ad99699..4fb23a5 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index dc6ebff..f419ebb 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index 9172f5f..5ac3a64 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt index 7695c05..ed22760 100644 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt +++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt @@ -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) + } + } } diff --git a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt new file mode 100644 index 0000000..cdc9197 --- /dev/null +++ b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt @@ -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() + .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) + } +} diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 25b050c..1fc304d 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -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) } } diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index 5d318c2..853a791 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -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 = _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() ?: "" diff --git a/client-mobile/core/notifications/data/ActiveChatTracker.kt b/client-mobile/core/notifications/data/ActiveChatTracker.kt new file mode 100644 index 0000000..4ae5ef9 --- /dev/null +++ b/client-mobile/core/notifications/data/ActiveChatTracker.kt @@ -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(null) + val currentChatId: StateFlow = _currentChatId.asStateFlow() + + fun setChatId(chatId: String?) { + _currentChatId.value = chatId + } +} diff --git a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt index 62c4c6d..142cb73 100644 --- a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt +++ b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt @@ -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() { diff --git a/client-mobile/core/notifications/data/NotificationHelper.kt b/client-mobile/core/notifications/data/NotificationHelper.kt new file mode 100644 index 0000000..3b58b26 --- /dev/null +++ b/client-mobile/core/notifications/data/NotificationHelper.kt @@ -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()) + } +} diff --git a/client-mobile/core/utils/NavigationManager.kt b/client-mobile/core/utils/NavigationManager.kt new file mode 100644 index 0000000..c2ce6d7 --- /dev/null +++ b/client-mobile/core/utils/NavigationManager.kt @@ -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(extraBufferCapacity = 1) + val events = _events + + fun navigateToChat(chatId: String) { + _events.tryEmit(NavEvent.OpenChat(chatId)) + } +} diff --git a/client-mobile/navigation/AppNavigation.kt b/client-mobile/navigation/AppNavigation.kt index df77805..1600736 100644 --- a/client-mobile/navigation/AppNavigation.kt +++ b/client-mobile/navigation/AppNavigation.kt @@ -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