Пуши
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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() ?: ""
|
||||
|
||||
|
||||
17
client-mobile/core/notifications/data/ActiveChatTracker.kt
Normal file
17
client-mobile/core/notifications/data/ActiveChatTracker.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
55
client-mobile/core/notifications/data/NotificationHelper.kt
Normal file
55
client-mobile/core/notifications/data/NotificationHelper.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
19
client-mobile/core/utils/NavigationManager.kt
Normal file
19
client-mobile/core/utils/NavigationManager.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user