diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 4d7091e6..bf990d64 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -1,6 +1,7 @@ package com.bitchat.android import android.Manifest +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -44,6 +45,9 @@ class MainActivity : ComponentActivity() { // Request necessary permissions requestPermissions() + // Handle notification intent if app was opened from a notification + handleNotificationIntent(intent) + setContent { BitchatTheme { Surface( @@ -56,18 +60,49 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + // Handle notification intents when app is already running + intent?.let { handleNotificationIntent(it) } + } + override fun onResume() { super.onResume() - // Notify that app is in foreground for power optimization + // Notify that app is in foreground for power optimization and notifications chatViewModel.setAppBackgroundState(false) } override fun onPause() { super.onPause() - // Notify that app is in background for power optimization + // Notify that app is in background for power optimization and notifications chatViewModel.setAppBackgroundState(true) } + /** + * Handle intents from notification clicks - open specific private chat + */ + private fun handleNotificationIntent(intent: Intent) { + val shouldOpenPrivateChat = intent.getBooleanExtra( + com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, + false + ) + + if (shouldOpenPrivateChat) { + val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID) + val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME) + + if (peerID != null) { + android.util.Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") + + // Open the private chat with this peer + chatViewModel.startPrivateChat(peerID) + + // Clear notifications for this sender since user is now viewing the chat + chatViewModel.clearNotificationsForSender(peerID) + } + } + } + private fun requestPermissions() { val permissions = mutableListOf() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index b1894c39..7d9a5055 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -149,11 +149,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B // MARK: - Private Chat Management (delegated) fun startPrivateChat(peerID: String) { - privateChatManager.startPrivateChat(peerID, meshService) + val success = privateChatManager.startPrivateChat(peerID, meshService) + if (success) { + // Notify notification manager about current private chat + setCurrentPrivateChatPeer(peerID) + // Clear notifications for this sender since user is now viewing the chat + clearNotificationsForSender(peerID) + } } fun endPrivateChat() { privateChatManager.endPrivateChat() + // Notify notification manager that no private chat is active + setCurrentPrivateChatPeer(null) } // MARK: - Message Sending @@ -266,6 +274,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B fun setAppBackgroundState(inBackground: Boolean) { // Forward to connection manager for power optimization meshService.connectionManager.setAppBackgroundState(inBackground) + + // Forward to notification manager for notification logic + notificationManager.setAppBackgroundState(inBackground) + } + + fun setCurrentPrivateChatPeer(peerID: String?) { + // Update notification manager with current private chat peer + notificationManager.setCurrentPrivateChatPeer(peerID) + } + + fun clearNotificationsForSender(peerID: String) { + // Clear notifications when user opens a chat + notificationManager.clearNotificationsForSender(peerID) } // MARK: - Command Autocomplete (delegated) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 4a98ae57..873d9c56 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -46,8 +46,18 @@ class MeshDelegateHandler( if (message.isPrivate) { // Private message privateChatManager.handleIncomingPrivateMessage(message) - if (state.getSelectedPrivateChatPeerValue() != message.senderPeerID) { - notificationManager.showPrivateMessageNotification(message.sender, message.content) + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + if (state.getSelectedPrivateChatPeerValue() != senderPeerID) { + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = message.content + ) + } } } else if (message.channel != null) { // Channel message diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 160eccf2..ce7e1e8a 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -2,15 +2,60 @@ package com.bitchat.android.ui 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 android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.MainActivity import com.bitchat.android.R +import java.util.concurrent.ConcurrentHashMap +/** + * Enhanced notification manager for direct messages with production-ready features: + * - Notification grouping per sender + * - Click handling to open specific DM + * - App background state awareness + * - Proper notification management and cleanup + */ class NotificationManager(private val context: Context) { - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val channelId = "bitchat_notifications" + companion object { + private const val TAG = "NotificationManager" + private const val CHANNEL_ID = "bitchat_dm_notifications" + private const val GROUP_KEY_DM = "bitchat_dm_group" + private const val NOTIFICATION_REQUEST_CODE = 1000 + private const val SUMMARY_NOTIFICATION_ID = 999 + + // Intent extras for notification handling + const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" + const val EXTRA_PEER_ID = "peer_id" + const val EXTRA_SENDER_NICKNAME = "sender_nickname" + } + + private val notificationManager = NotificationManagerCompat.from(context) + private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Track pending notifications per sender to enable grouping + private val pendingNotifications = ConcurrentHashMap>() + + // Track app background state + @Volatile + private var isAppInBackground = false + + // Track current view state + @Volatile + private var currentPrivateChatPeer: String? = null + + data class PendingNotification( + val senderPeerID: String, + val senderNickname: String, + val messageContent: String, + val timestamp: Long + ) init { createNotificationChannel() @@ -18,24 +63,261 @@ class NotificationManager(private val context: Context) { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = "Bitchat Notifications" - val descriptionText = "Notifications for new messages and other events" - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(channelId, name, importance).apply { + val name = "Direct Messages" + val descriptionText = "Notifications for private messages from other users" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { description = descriptionText + enableVibration(true) + setShowBadge(true) } - notificationManager.createNotificationChannel(channel) + systemNotificationManager.createNotificationChannel(channel) } } - fun showPrivateMessageNotification(sender: String, message: String) { - val builder = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("New message from $sender") - .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) + /** + * Update app background state - notifications should only be shown when app is backgrounded + */ + fun setAppBackgroundState(inBackground: Boolean) { + isAppInBackground = inBackground + Log.d(TAG, "App background state changed: $inBackground") + } - notificationManager.notify(System.currentTimeMillis().toInt(), builder.build()) + /** + * Update current private chat peer - affects notification logic + */ + fun setCurrentPrivateChatPeer(peerID: String?) { + currentPrivateChatPeer = peerID + Log.d(TAG, "Current private chat peer changed: $peerID") + } + + /** + * Show a notification for a private message with proper grouping and state awareness + */ + fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) { + // Only show notifications if app is in background OR user is not viewing this specific chat + val shouldNotify = isAppInBackground || currentPrivateChatPeer != senderPeerID + + if (!shouldNotify) { + Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname") + return + } + + Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)") + + val notification = PendingNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = messageContent, + timestamp = System.currentTimeMillis() + ) + + // Add to pending notifications for this sender + pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification) + + // Create or update notification for this sender + showNotificationForSender(senderPeerID) + + // Update summary notification if we have multiple senders + if (pendingNotifications.size > 1) { + showSummaryNotification() + } + } + + private fun showNotificationForSender(senderPeerID: String) { + val notifications = pendingNotifications[senderPeerID] ?: return + if (notifications.isEmpty()) return + + val latestNotification = notifications.last() + val messageCount = notifications.size + + // Create intent to open the specific private chat + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, senderPeerID) + putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname) + } + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Create person for better notification styling + val person = Person.Builder() + .setName(latestNotification.senderNickname) + .setKey(senderPeerID) + .build() + + // Build notification content + val contentText = if (messageCount == 1) { + latestNotification.messageContent + } else { + "${latestNotification.messageContent} (+${messageCount - 1} more)" + } + + val contentTitle = if (messageCount == 1) { + latestNotification.senderNickname + } else { + "${latestNotification.senderNickname} ($messageCount messages)" + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .addPerson(person) + .setShowWhen(true) + .setWhen(latestNotification.timestamp) + + // Add to notification group if we have multiple senders + if (pendingNotifications.size > 1) { + builder.setGroup(GROUP_KEY_DM) + } + + // Add style for multiple messages + if (messageCount > 1) { + val style = NotificationCompat.InboxStyle() + .setBigContentTitle(contentTitle) + + // Show last few messages in expanded view + notifications.takeLast(5).forEach { notif -> + style.addLine(notif.messageContent) + } + + if (messageCount > 5) { + style.setSummaryText("and ${messageCount - 5} more") + } + + builder.setStyle(style) + } else { + // Single message - use BigTextStyle for long messages + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(latestNotification.messageContent) + ) + } + + // Use sender peer ID hash as notification ID to group messages from same sender + val notificationId = senderPeerID.hashCode() + notificationManager.notify(notificationId, builder.build()) + + Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") + } + + private fun showSummaryNotification() { + if (pendingNotifications.isEmpty()) return + + val totalMessages = pendingNotifications.values.sumOf { it.size } + val senderCount = pendingNotifications.size + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_REQUEST_CODE, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("bitchat") + .setContentText("$totalMessages messages from $senderCount people") + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(GROUP_KEY_DM) + .setGroupSummary(true) + + // Add inbox style showing recent senders + val style = NotificationCompat.InboxStyle() + .setBigContentTitle("New Messages") + + pendingNotifications.entries.take(5).forEach { (peerID, notifications) -> + val latestNotif = notifications.last() + val count = notifications.size + val line = if (count == 1) { + "${latestNotif.senderNickname}: ${latestNotif.messageContent}" + } else { + "${latestNotif.senderNickname}: $count messages" + } + style.addLine(line) + } + + if (pendingNotifications.size > 5) { + style.setSummaryText("and ${pendingNotifications.size - 5} more conversations") + } + + builder.setStyle(style) + + notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) + + Log.d(TAG, "Displayed summary notification for $senderCount senders") + } + + /** + * Clear notifications for a specific sender (e.g., when user opens their chat) + */ + fun clearNotificationsForSender(senderPeerID: String) { + pendingNotifications.remove(senderPeerID) + + // Cancel the individual notification + val notificationId = senderPeerID.hashCode() + notificationManager.cancel(notificationId) + + // Update or remove summary notification + if (pendingNotifications.isEmpty()) { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } else if (pendingNotifications.size == 1) { + // Only one sender left, remove group summary + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } else { + // Update summary notification + showSummaryNotification() + } + + Log.d(TAG, "Cleared notifications for sender: $senderPeerID") + } + + /** + * Clear all pending notifications + */ + fun clearAllNotifications() { + pendingNotifications.clear() + notificationManager.cancelAll() + Log.d(TAG, "Cleared all notifications") + } + + /** + * Get pending notification count for UI badging + */ + fun getPendingNotificationCount(): Int { + return pendingNotifications.values.sumOf { it.size } + } + + /** + * Get pending notifications for debugging + */ + fun getDebugInfo(): String { + return buildString { + appendLine("Notification Manager Debug Info:") + appendLine("App in background: $isAppInBackground") + appendLine("Current private chat peer: $currentPrivateChatPeer") + appendLine("Pending notifications: ${pendingNotifications.size} senders") + pendingNotifications.forEach { (peerID, notifications) -> + appendLine(" $peerID: ${notifications.size} messages") + } + } } }