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 c72ae432..9fdbc19a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1,8 +1,8 @@ package com.bitchat.android.ui import android.app.Application -import android.content.Context import android.util.Log +import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope @@ -11,10 +11,11 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.nostr.NostrGeohashService - import kotlinx.coroutines.launch +import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay -import java.util.* +import kotlinx.coroutines.launch +import java.util.Date import kotlin.random.Random /** @@ -47,7 +48,12 @@ class ChatViewModel( val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) - private val notificationManager = NotificationManager(application.applicationContext) + private val notificationManager = NotificationManager( + application.applicationContext, + NotificationManagerCompat.from(application.applicationContext), + NotificationIntervalManager() + ) + // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( state = state, @@ -72,7 +78,7 @@ class ChatViewModel( dataManager = dataManager, notificationManager = notificationManager ) - + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -106,7 +112,7 @@ class ChatViewModel( val geohashPeople: LiveData> = state.geohashPeople val teleportedGeo: LiveData> = state.teleportedGeo val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts - + init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() @@ -136,7 +142,7 @@ class ChatViewModel( state.setFavoritePeers(dataManager.favoritePeers.toSet()) dataManager.loadBlockedUsers() dataManager.loadGeohashBlockedUsers() - + // Log all favorites at startup dataManager.logAllFavorites() logCurrentFavoriteState() @@ -146,10 +152,10 @@ class ChatViewModel( // Initialize location channel state nostrGeohashService.initializeLocationChannelState() - + // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) - + // Initialize Nostr integration nostrGeohashService.initializeNostrIntegration() @@ -158,7 +164,7 @@ class ChatViewModel( val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) nostrTransport.senderPeerID = meshService.myPeerID } catch (_: Exception) { } - + // Note: Mesh service is now started by MainActivity // Show welcome message if no peers after delay @@ -299,16 +305,16 @@ class ChatViewModel( mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) - + if (currentChannelValue != null) { channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) - + // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { channelManager.sendEncryptedChannelMessage( - content, - mentions, - currentChannelValue, + content, + mentions, + currentChannelValue, state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> @@ -329,9 +335,7 @@ class ChatViewModel( } } } - - // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { @@ -434,23 +438,7 @@ class ChatViewModel( val rssiValues = meshService.getPeerRSSI() state.setPeerRSSI(rssiValues) } - - - - - - - - - - - - - - - - // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { @@ -474,7 +462,7 @@ class ChatViewModel( // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } - + fun clearNotificationsForSender(peerID: String) { // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) @@ -484,14 +472,14 @@ class ChatViewModel( // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - + /** * Clear mesh mention notifications when user opens mesh chat */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } - + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { @@ -570,7 +558,7 @@ class ChatViewModel( // Clear geohash message history nostrGeohashService.clearGeohashMessageHistory() - + // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) @@ -618,55 +606,35 @@ class ChatViewModel( Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") } } - - - - - - - - /** * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { return nostrGeohashService.geohashParticipantCount(geohash) } - + /** * Begin sampling multiple geohashes for participant activity */ fun beginGeohashSampling(geohashes: List) { nostrGeohashService.beginGeohashSampling(geohashes) } - + /** * End geohash sampling */ fun endGeohashSampling() { nostrGeohashService.endGeohashSampling() } - - - - - - - - - - - - /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { return nostrGeohashService.isPersonTeleported(pubkeyHex) } - + /** * Start geohash DM with pubkey hex (iOS-compatible) */ @@ -675,30 +643,18 @@ class ChatViewModel( startPrivateChat(convKey) } } - - - - fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { nostrGeohashService.selectLocationChannel(channel) } - + /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { nostrGeohashService.blockUserInGeohash(targetNickname) } - - - - - - - - // MARK: - Navigation Management fun showAppInfo() { @@ -753,9 +709,9 @@ class ChatViewModel( else -> false } } - + // MARK: - iOS-Compatible Color System - + /** * Get consistent color for a mesh peer by ID (iOS-compatible) */ @@ -764,13 +720,11 @@ class ChatViewModel( val seed = "noise:${peerID.lowercase()}" return colorForPeerSeed(seed, isDark).copy() } - + /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark) } - - } 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 824ca81d..30a7ec21 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -1,13 +1,12 @@ package com.bitchat.android.ui -import androidx.lifecycle.LifecycleCoroutineScope import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.util.* +import java.util.Date /** * Handles all BluetoothMeshDelegate callbacks and routes them to appropriate managers @@ -70,7 +69,7 @@ class MeshDelegateHandler( } else { // Public message messageManager.addMessage(message) - + // Check for mentions in mesh chat checkAndTriggerMeshMentionNotification(message) } @@ -86,9 +85,10 @@ class MeshDelegateHandler( coroutineScope.launch { state.setConnectedPeers(peers) state.setIsConnected(peers.isNotEmpty()) + notificationManager.showActiveUserNotification(peers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } - + // Clean up channel members who disconnected channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) @@ -180,7 +180,7 @@ class MeshDelegateHandler( private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { coroutineScope.launch { channelManager.removeChannelMember(channel, fromPeer) @@ -219,13 +219,13 @@ class MeshDelegateHandler( if (currentNickname.isNullOrEmpty()) { return } - + // Check if this message mentions the current user using @username format val isMention = checkForMeshMention(message.content, currentNickname) - + if (isMention) { android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from ${message.sender}") - + notificationManager.showMeshMentionNotification( senderNickname = message.sender, messageContent = message.content, @@ -236,14 +236,14 @@ class MeshDelegateHandler( android.util.Log.e("MeshDelegateHandler", "Error checking mesh mentions: ${e.message}") } } - + /** * Check if the content mentions the current user with @username format (simple, no hash suffix) */ private fun checkForMeshMention(content: String, currentNickname: String): Boolean { // Simple mention pattern for mesh: @username (no hash suffix like geohash) val mentionPattern = "@([\\p{L}0-9_]+)".toRegex() - + return mentionPattern.findAll(content).any { match -> val mentionedUsername = match.groupValues[1] // Direct comparison for mesh mentions (no hash suffix to remove) 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 79c654ee..857160ec 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -12,6 +12,7 @@ import androidx.core.app.Person import androidx.core.app.NotificationManagerCompat import com.bitchat.android.MainActivity import com.bitchat.android.R +import com.bitchat.android.util.NotificationIntervalManager import java.util.concurrent.ConcurrentHashMap /** @@ -22,8 +23,13 @@ import java.util.concurrent.ConcurrentHashMap * - Support for mention notifications in geohash chats * - Support for first message notifications in geohash chats * - Proper notification management and cleanup + * - Active peers notification */ -class NotificationManager(private val context: Context) { +class NotificationManager( + private val context: Context, + private val notificationManager: NotificationManagerCompat, + private val notificationIntervalManager: NotificationIntervalManager +) { companion object { private const val TAG = "NotificationManager" @@ -34,8 +40,10 @@ class NotificationManager(private val context: Context) { private const val NOTIFICATION_REQUEST_CODE = 1000 private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000 private const val SUMMARY_NOTIFICATION_ID = 999 - private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998 - + private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998 + private const val ACTIVE_PEERS_NOTIFICATION_ID = 997 + private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = 300_000L + // Intent extras for notification handling const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" const val EXTRA_OPEN_GEOHASH_CHAT = "open_geohash_chat" @@ -44,13 +52,12 @@ class NotificationManager(private val context: Context) { const val EXTRA_GEOHASH = "geohash" } - 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>() private val pendingGeohashNotifications = ConcurrentHashMap>() - + // Track app background state @Volatile private var isAppInBackground = false @@ -58,7 +65,7 @@ class NotificationManager(private val context: Context) { // Track current view state @Volatile private var currentPrivateChatPeer: String? = null - + @Volatile private var currentGeohash: String? = null @@ -68,7 +75,7 @@ class NotificationManager(private val context: Context) { val messageContent: String, val timestamp: Long ) - + data class GeohashNotification( val geohash: String, val senderNickname: String, @@ -94,7 +101,7 @@ class NotificationManager(private val context: Context) { setShowBadge(true) } systemNotificationManager.createNotificationChannel(dmChannel) - + // Geohash notifications channel val geohashName = "Geohash Chats" val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels" @@ -123,7 +130,7 @@ class NotificationManager(private val context: Context) { currentPrivateChatPeer = peerID Log.d(TAG, "Current private chat peer changed: $peerID") } - + /** * Update current geohash - affects notification logic for geohash chats */ @@ -165,6 +172,22 @@ class NotificationManager(private val context: Context) { } } + fun showActiveUserNotification(peers: List) { + val currentTime = System.currentTimeMillis() + val activePeerNotificationIntervalExceeded = + (currentTime - notificationIntervalManager.lastNetworkNotificationTime) > ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL + val newPeers = peers - notificationIntervalManager.recentlySeenPeers + if (isAppInBackground && activePeerNotificationIntervalExceeded && newPeers.isNotEmpty()) { + Log.d(TAG, "Showing notification for active peers") + showNotificationForActivePeers(peers.size) + notificationIntervalManager.setLastNetworkNotificationTime(currentTime) + notificationIntervalManager.recentlySeenPeers.addAll(newPeers) + } else { + Log.d(TAG, "Skipping notification - app in foreground or it has been less than 5 minutes since last active peer notification") + return + } + } + private fun showNotificationForSender(senderPeerID: String) { val notifications = pendingNotifications[senderPeerID] ?: return if (notifications.isEmpty()) return @@ -249,10 +272,45 @@ class NotificationManager(private val context: Context) { // 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 showNotificationForActivePeers(peersSize: Int) { + // Create intent to open the app + 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, + ACTIVE_PEERS_NOTIFICATION_ID, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Build notification content + val contentTitle = "👥 bitchatters nearby!" + val contentText = if (peersSize == 1) { + "1 person around" + } else { + "$peersSize people around" + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + + notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build()) + Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID") + } private fun showSummaryNotification() { if (pendingNotifications.isEmpty()) return @@ -303,7 +361,7 @@ class NotificationManager(private val context: Context) { builder.setStyle(style) notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) - + Log.d(TAG, "Displayed summary notification for $senderCount senders") } @@ -316,7 +374,7 @@ class NotificationManager(private val context: Context) { // Cancel the individual notification val notificationId = senderPeerID.hashCode() notificationManager.cancel(notificationId) - + // Update or remove summary notification if (pendingNotifications.isEmpty()) { notificationManager.cancel(SUMMARY_NOTIFICATION_ID) @@ -343,7 +401,7 @@ class NotificationManager(private val context: Context) { ) { // Only show notifications if app is in background OR user is not viewing this specific geohash val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash) - + if (!shouldNotify) { Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash") return @@ -365,13 +423,13 @@ class NotificationManager(private val context: Context) { // Create or update notification for this geohash showNotificationForGeohash(geohash) - + // Update summary notification if we have multiple geohashes if (pendingGeohashNotifications.size > 1) { showGeohashSummaryNotification() } } - + private fun showNotificationForGeohash(geohash: String) { val notifications = pendingGeohashNotifications[geohash] ?: return if (notifications.isEmpty()) return @@ -402,7 +460,7 @@ class NotificationManager(private val context: Context) { firstMessageCount > 0 -> "New activity in #$geohash" else -> "Messages in #$geohash" } - + val contentText = when { latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation" @@ -429,7 +487,7 @@ class NotificationManager(private val context: Context) { if (messageCount > 1) { val style = NotificationCompat.InboxStyle() .setBigContentTitle(contentTitle) - + // Show last few messages in expanded view notifications.takeLast(5).forEach { notif -> val prefix = when { @@ -439,11 +497,11 @@ class NotificationManager(private val context: Context) { } style.addLine("$prefix${notif.senderNickname}: ${notif.messageContent}") } - + if (messageCount > 5) { style.setSummaryText("and ${messageCount - 5} more") } - + builder.setStyle(style) } else { // Single message - use BigTextStyle for long messages @@ -456,10 +514,10 @@ class NotificationManager(private val context: Context) { // Use geohash hash as notification ID to group messages from same geohash val notificationId = 3000 + geohash.hashCode() notificationManager.notify(notificationId, builder.build()) - + Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId") } - + private fun showGeohashSummaryNotification() { if (pendingGeohashNotifications.isEmpty()) return @@ -485,7 +543,7 @@ class NotificationManager(private val context: Context) { } else { "bitchat - location chats" } - + val contentText = "$totalMessages messages from $geohashCount locations" val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID) @@ -502,7 +560,7 @@ class NotificationManager(private val context: Context) { // Add inbox style showing recent geohashes val style = NotificationCompat.InboxStyle() .setBigContentTitle("New Location Messages") - + pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) -> val mentionCount = notifications.count { it.isMention } val messageCount = notifications.size @@ -513,15 +571,15 @@ class NotificationManager(private val context: Context) { } style.addLine(line) } - + if (pendingGeohashNotifications.size > 5) { style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations") } - + builder.setStyle(style) notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) - + Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations") } @@ -530,11 +588,11 @@ class NotificationManager(private val context: Context) { */ fun clearNotificationsForGeohash(geohash: String) { pendingGeohashNotifications.remove(geohash) - + // Cancel the individual notification val notificationId = 3000 + geohash.hashCode() notificationManager.cancel(notificationId) - + // Update or remove summary notification if (pendingGeohashNotifications.isEmpty()) { notificationManager.cancel(GEOHASH_SUMMARY_NOTIFICATION_ID) @@ -545,10 +603,10 @@ class NotificationManager(private val context: Context) { // Update summary notification showGeohashSummaryNotification() } - + Log.d(TAG, "Cleared notifications for geohash: $geohash") } - + /** * Show a notification for a mesh mention (@username format) */ @@ -561,7 +619,7 @@ class NotificationManager(private val context: Context) { // User is viewing mesh chat when: not in private chat AND not in geohash chat val isViewingMeshChat = currentPrivateChatPeer == null && currentGeohash == null val shouldNotify = isAppInBackground || (!isAppInBackground && !isViewingMeshChat) - + if (!shouldNotify) { Log.d(TAG, "Skipping mesh mention notification - app in foreground and viewing mesh chat") return @@ -583,13 +641,13 @@ class NotificationManager(private val context: Context) { // Create or update notification for mesh mentions showNotificationForMeshMentions() - + // Update summary notification if we have multiple senders if (pendingNotifications.size > 1) { showSummaryNotification() } } - + private fun showNotificationForMeshMentions() { val notifications = pendingNotifications["mesh_mentions"] ?: return if (notifications.isEmpty()) return @@ -616,7 +674,7 @@ class NotificationManager(private val context: Context) { } else { "$messageCount mentions in Mesh Chat" } - + val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}" val builder = NotificationCompat.Builder(context, CHANNEL_ID) @@ -639,16 +697,16 @@ class NotificationManager(private val context: Context) { if (messageCount > 1) { val style = NotificationCompat.InboxStyle() .setBigContentTitle(contentTitle) - + // Show last few messages in expanded view notifications.takeLast(5).forEach { notif -> style.addLine("${notif.senderNickname}: ${notif.messageContent}") } - + if (messageCount > 5) { style.setSummaryText("and ${messageCount - 5} more") } - + builder.setStyle(style) } else { // Single message - use BigTextStyle for long messages @@ -661,20 +719,20 @@ class NotificationManager(private val context: Context) { // Use a special notification ID for mesh mentions val notificationId = 4000 // Different from DM and geohash IDs notificationManager.notify(notificationId, builder.build()) - + Log.d(TAG, "Displayed mesh mention notification: $contentTitle") } - + /** * Clear mesh mention notifications (when user opens mesh chat) */ fun clearMeshMentionNotifications() { pendingNotifications.remove("mesh_mentions") - + // Cancel the mesh mention notification val notificationId = 4000 notificationManager.cancel(notificationId) - + // Update or remove summary notification if (pendingNotifications.isEmpty()) { notificationManager.cancel(SUMMARY_NOTIFICATION_ID) @@ -685,7 +743,7 @@ class NotificationManager(private val context: Context) { // Update summary notification showSummaryNotification() } - + Log.d(TAG, "Cleared mesh mention notifications") } @@ -694,8 +752,8 @@ class NotificationManager(private val context: Context) { */ fun clearAllNotifications() { pendingNotifications.clear() - pendingGeohashNotifications.clear() notificationManager.cancelAll() + pendingGeohashNotifications.clear() Log.d(TAG, "Cleared all notifications") } @@ -703,7 +761,7 @@ class NotificationManager(private val context: Context) { * Get pending notification count for UI badging */ fun getPendingNotificationCount(): Int { - return pendingNotifications.values.sumOf { it.size } + + return pendingNotifications.values.sumOf { it.size } + pendingGeohashNotifications.values.sumOf { it.size } } diff --git a/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt b/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt new file mode 100644 index 00000000..ead07448 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt @@ -0,0 +1,13 @@ +package com.bitchat.android.util + +class NotificationIntervalManager { + private var _lastNetworkNotificationTime = 0L + val lastNetworkNotificationTime: Long + get() = _lastNetworkNotificationTime + + val recentlySeenPeers: MutableSet = mutableSetOf() + + fun setLastNetworkNotificationTime(notificationTime: Long) { + _lastNetworkNotificationTime = notificationTime + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt b/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt new file mode 100644 index 00000000..9f821fcb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt @@ -0,0 +1,110 @@ +package com.bitchat + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.ui.NotificationManager +import com.bitchat.android.util.NotificationIntervalManager +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.MockitoAnnotations +import org.mockito.Spy +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NotificationManagerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val notificationIntervalManager = NotificationIntervalManager() + lateinit var notificationManager: NotificationManager + + @Spy + val notificationManagerCompat: NotificationManagerCompat = + Mockito.spy(NotificationManagerCompat.from(context)) + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + notificationManager = NotificationManager( + context, + notificationManagerCompat, + notificationIntervalManager + ) + } + + @Test + fun `when there are no active peers, do not send active peer notification`() { + notificationManager.setAppBackgroundState(true) + notificationManager.showActiveUserNotification(emptyList()) + verify(notificationManagerCompat, never()).notify(any(), any()) + } + + @Test + fun `when app is in foreground, do not send active peer notification`() { + notificationManager.setAppBackgroundState(false) + notificationManager.showActiveUserNotification(listOf("peer-1")) + verify(notificationManagerCompat, never()).notify(any(), any()) + } + + @Test + fun `when there is an active peer, send notification`() { + notificationManager.setAppBackgroundState(true) + notificationManager.showActiveUserNotification(listOf("peer-1")) + verify(notificationManagerCompat, times(1)).notify(any(), any()) + } + + @Test + fun `when there is an active peer but less than 5 minutes have passed since last notification, do not send notification`() { + notificationManager.setAppBackgroundState(true) + notificationManager.showActiveUserNotification(listOf("peer-1")) + notificationManager.showActiveUserNotification(listOf("peer-2")) + verify(notificationManagerCompat, times(1)).notify(any(), any()) + } + + @Test + fun `when there is an active peer and more than 5 minutes have passed since last notification, send notification`() { + notificationManager.setAppBackgroundState(true) + notificationManager.showActiveUserNotification(listOf("peer-1")) + notificationIntervalManager.setLastNetworkNotificationTime(System.currentTimeMillis() - 301_000L) + notificationManager.showActiveUserNotification(listOf("peer-2")) + verify(notificationManagerCompat, times(2)).notify(any(), any()) + } + + @Test + fun `when there is a recently seen peer but no new active peers, no notification is sent`() { + notificationManager.setAppBackgroundState(true) + notificationIntervalManager.recentlySeenPeers.add("peer-1") + notificationManager.showActiveUserNotification(emptyList()) + verify(notificationManagerCompat, times(0)).notify(any(), any()) + } + + @Test + fun `when an active peer is a recently seen peer, do not send notification`() { + notificationManager.setAppBackgroundState(true) + notificationIntervalManager.recentlySeenPeers.add("peer-1") + notificationManager.showActiveUserNotification(listOf("peer-1")) + verify(notificationManagerCompat, times(0)).notify(any(), any()) + } + + @Test + fun `when an active peer is a new peer, send notification`() { + notificationManager.setAppBackgroundState(true) + notificationIntervalManager.recentlySeenPeers.addAll(emptyList()) + notificationManager.showActiveUserNotification(listOf("peer-1")) + verify(notificationManagerCompat, times(1)).notify(any(), any()) + } + + @Test + fun `when an active peer is a new peer and there are already multiple recently seen peers, send notification`() { + notificationManager.setAppBackgroundState(true) + notificationIntervalManager.recentlySeenPeers.addAll(listOf("peer-1", "peer-2")) + notificationManager.showActiveUserNotification(listOf("peer-3")) + verify(notificationManagerCompat, times(1)).notify(any(), any()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2eeb7748..fe9d7e12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,9 @@ security-crypto = "1.1.0-beta01" junit = "4.13.2" androidx-test-ext = "1.2.1" espresso = "3.6.1" +mockito-kotlin = "4.1.0" +mockito-inline = "4.1.0" +roboelectric = "4.15" [libraries] # AndroidX Core @@ -103,6 +106,9 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } +roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -135,7 +141,10 @@ cryptography = [ testing = [ "junit", "androidx-test-ext-junit", - "androidx-test-espresso-core" + "androidx-test-espresso-core", + "mockito-kotlin", + "mockito-inline", + "roboelectric" ] compose-testing = [