Add active peer notification (#273)

* adding notification for active peers + tests

* adding a recently seen peer set to track if we've seen that peer before

* changing back to notificationManager naming

* fixing some weird formatting that occurred during merge conflict fix
This commit is contained in:
Minh
2025-08-28 09:17:41 +02:00
committed by GitHub
parent 28abd3c593
commit 02d5466812
6 changed files with 277 additions and 133 deletions
@@ -1,8 +1,8 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import android.app.Application import android.app.Application
import android.content.Context
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -11,10 +11,11 @@ import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.nostr.NostrGeohashService import com.bitchat.android.nostr.NostrGeohashService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.* import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -47,7 +48,12 @@ class ChatViewModel(
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) 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 // Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler( private val meshDelegateHandler = MeshDelegateHandler(
state = state, state = state,
@@ -72,7 +78,7 @@ class ChatViewModel(
dataManager = dataManager, dataManager = dataManager,
notificationManager = notificationManager notificationManager = notificationManager
) )
// Expose state through LiveData (maintaining the same interface) // Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers val connectedPeers: LiveData<List<String>> = state.connectedPeers
@@ -106,7 +112,7 @@ class ChatViewModel(
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
init { init {
// Note: Mesh service delegate is now set by MainActivity // Note: Mesh service delegate is now set by MainActivity
loadAndInitialize() loadAndInitialize()
@@ -136,7 +142,7 @@ class ChatViewModel(
state.setFavoritePeers(dataManager.favoritePeers.toSet()) state.setFavoritePeers(dataManager.favoritePeers.toSet())
dataManager.loadBlockedUsers() dataManager.loadBlockedUsers()
dataManager.loadGeohashBlockedUsers() dataManager.loadGeohashBlockedUsers()
// Log all favorites at startup // Log all favorites at startup
dataManager.logAllFavorites() dataManager.logAllFavorites()
logCurrentFavoriteState() logCurrentFavoriteState()
@@ -146,10 +152,10 @@ class ChatViewModel(
// Initialize location channel state // Initialize location channel state
nostrGeohashService.initializeLocationChannelState() nostrGeohashService.initializeLocationChannelState()
// Initialize favorites persistence service // Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Initialize Nostr integration // Initialize Nostr integration
nostrGeohashService.initializeNostrIntegration() nostrGeohashService.initializeNostrIntegration()
@@ -158,7 +164,7 @@ class ChatViewModel(
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
} catch (_: Exception) { } } catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity // Note: Mesh service is now started by MainActivity
// Show welcome message if no peers after delay // Show welcome message if no peers after delay
@@ -299,16 +305,16 @@ class ChatViewModel(
mentions = if (mentions.isNotEmpty()) mentions else null, mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue channel = currentChannelValue
) )
if (currentChannelValue != null) { if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
// Check if encrypted channel // Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) { if (channelManager.hasChannelKey(currentChannelValue)) {
channelManager.sendEncryptedChannelMessage( channelManager.sendEncryptedChannelMessage(
content, content,
mentions, mentions,
currentChannelValue, currentChannelValue,
state.getNicknameValue(), state.getNicknameValue(),
meshService.myPeerID, meshService.myPeerID,
onEncryptedPayload = { encryptedData -> onEncryptedPayload = { encryptedData ->
@@ -329,9 +335,7 @@ class ChatViewModel(
} }
} }
} }
// MARK: - Utility Functions // MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? { fun getPeerIDForNickname(nickname: String): String? {
@@ -434,23 +438,7 @@ class ChatViewModel(
val rssiValues = meshService.getPeerRSSI() val rssiValues = meshService.getPeerRSSI()
state.setPeerRSSI(rssiValues) state.setPeerRSSI(rssiValues)
} }
// MARK: - Debug and Troubleshooting // MARK: - Debug and Troubleshooting
fun getDebugStatus(): String { fun getDebugStatus(): String {
@@ -474,7 +462,7 @@ class ChatViewModel(
// Update notification manager with current geohash for notification logic // Update notification manager with current geohash for notification logic
notificationManager.setCurrentGeohash(geohash) notificationManager.setCurrentGeohash(geohash)
} }
fun clearNotificationsForSender(peerID: String) { fun clearNotificationsForSender(peerID: String) {
// Clear notifications when user opens a chat // Clear notifications when user opens a chat
notificationManager.clearNotificationsForSender(peerID) notificationManager.clearNotificationsForSender(peerID)
@@ -484,14 +472,14 @@ class ChatViewModel(
// Clear notifications when user opens a geohash chat // Clear notifications when user opens a geohash chat
notificationManager.clearNotificationsForGeohash(geohash) notificationManager.clearNotificationsForGeohash(geohash)
} }
/** /**
* Clear mesh mention notifications when user opens mesh chat * Clear mesh mention notifications when user opens mesh chat
*/ */
fun clearMeshMentionNotifications() { fun clearMeshMentionNotifications() {
notificationManager.clearMeshMentionNotifications() notificationManager.clearMeshMentionNotifications()
} }
// MARK: - Command Autocomplete (delegated) // MARK: - Command Autocomplete (delegated)
fun updateCommandSuggestions(input: String) { fun updateCommandSuggestions(input: String) {
@@ -570,7 +558,7 @@ class ChatViewModel(
// Clear geohash message history // Clear geohash message history
nostrGeohashService.clearGeohashMessageHistory() nostrGeohashService.clearGeohashMessageHistory()
// Reset nickname // Reset nickname
val newNickname = "anon${Random.nextInt(1000, 9999)}" val newNickname = "anon${Random.nextInt(1000, 9999)}"
state.setNickname(newNickname) state.setNickname(newNickname)
@@ -618,55 +606,35 @@ class ChatViewModel(
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
} }
} }
/** /**
* Get participant count for a specific geohash (5-minute activity window) * Get participant count for a specific geohash (5-minute activity window)
*/ */
fun geohashParticipantCount(geohash: String): Int { fun geohashParticipantCount(geohash: String): Int {
return nostrGeohashService.geohashParticipantCount(geohash) return nostrGeohashService.geohashParticipantCount(geohash)
} }
/** /**
* Begin sampling multiple geohashes for participant activity * Begin sampling multiple geohashes for participant activity
*/ */
fun beginGeohashSampling(geohashes: List<String>) { fun beginGeohashSampling(geohashes: List<String>) {
nostrGeohashService.beginGeohashSampling(geohashes) nostrGeohashService.beginGeohashSampling(geohashes)
} }
/** /**
* End geohash sampling * End geohash sampling
*/ */
fun endGeohashSampling() { fun endGeohashSampling() {
nostrGeohashService.endGeohashSampling() nostrGeohashService.endGeohashSampling()
} }
/** /**
* Check if a geohash person is teleported (iOS-compatible) * Check if a geohash person is teleported (iOS-compatible)
*/ */
fun isPersonTeleported(pubkeyHex: String): Boolean { fun isPersonTeleported(pubkeyHex: String): Boolean {
return nostrGeohashService.isPersonTeleported(pubkeyHex) return nostrGeohashService.isPersonTeleported(pubkeyHex)
} }
/** /**
* Start geohash DM with pubkey hex (iOS-compatible) * Start geohash DM with pubkey hex (iOS-compatible)
*/ */
@@ -675,30 +643,18 @@ class ChatViewModel(
startPrivateChat(convKey) startPrivateChat(convKey)
} }
} }
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
nostrGeohashService.selectLocationChannel(channel) nostrGeohashService.selectLocationChannel(channel)
} }
/** /**
* Block a user in geohash channels by their nickname * Block a user in geohash channels by their nickname
*/ */
fun blockUserInGeohash(targetNickname: String) { fun blockUserInGeohash(targetNickname: String) {
nostrGeohashService.blockUserInGeohash(targetNickname) nostrGeohashService.blockUserInGeohash(targetNickname)
} }
// MARK: - Navigation Management // MARK: - Navigation Management
fun showAppInfo() { fun showAppInfo() {
@@ -753,9 +709,9 @@ class ChatViewModel(
else -> false else -> false
} }
} }
// MARK: - iOS-Compatible Color System // MARK: - iOS-Compatible Color System
/** /**
* Get consistent color for a mesh peer by ID (iOS-compatible) * Get consistent color for a mesh peer by ID (iOS-compatible)
*/ */
@@ -764,13 +720,11 @@ class ChatViewModel(
val seed = "noise:${peerID.lowercase()}" val seed = "noise:${peerID.lowercase()}"
return colorForPeerSeed(seed, isDark).copy() return colorForPeerSeed(seed, isDark).copy()
} }
/** /**
* Get consistent color for a Nostr pubkey (iOS-compatible) * Get consistent color for a Nostr pubkey (iOS-compatible)
*/ */
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark) return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
} }
} }
@@ -1,13 +1,12 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import androidx.lifecycle.LifecycleCoroutineScope
import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.Date
/** /**
* Handles all BluetoothMeshDelegate callbacks and routes them to appropriate managers * Handles all BluetoothMeshDelegate callbacks and routes them to appropriate managers
@@ -70,7 +69,7 @@ class MeshDelegateHandler(
} else { } else {
// Public message // Public message
messageManager.addMessage(message) messageManager.addMessage(message)
// Check for mentions in mesh chat // Check for mentions in mesh chat
checkAndTriggerMeshMentionNotification(message) checkAndTriggerMeshMentionNotification(message)
} }
@@ -86,9 +85,10 @@ class MeshDelegateHandler(
coroutineScope.launch { coroutineScope.launch {
state.setConnectedPeers(peers) state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty()) state.setIsConnected(peers.isNotEmpty())
notificationManager.showActiveUserNotification(peers)
// Flush router outbox for any peers that just connected (and their noiseHex aliases) // Flush router outbox for any peers that just connected (and their noiseHex aliases)
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
// Clean up channel members who disconnected // Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
@@ -180,7 +180,7 @@ class MeshDelegateHandler(
private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) { private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
} }
override fun didReceiveChannelLeave(channel: String, fromPeer: String) { override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
coroutineScope.launch { coroutineScope.launch {
channelManager.removeChannelMember(channel, fromPeer) channelManager.removeChannelMember(channel, fromPeer)
@@ -219,13 +219,13 @@ class MeshDelegateHandler(
if (currentNickname.isNullOrEmpty()) { if (currentNickname.isNullOrEmpty()) {
return return
} }
// Check if this message mentions the current user using @username format // Check if this message mentions the current user using @username format
val isMention = checkForMeshMention(message.content, currentNickname) val isMention = checkForMeshMention(message.content, currentNickname)
if (isMention) { if (isMention) {
android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from ${message.sender}") android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from ${message.sender}")
notificationManager.showMeshMentionNotification( notificationManager.showMeshMentionNotification(
senderNickname = message.sender, senderNickname = message.sender,
messageContent = message.content, messageContent = message.content,
@@ -236,14 +236,14 @@ class MeshDelegateHandler(
android.util.Log.e("MeshDelegateHandler", "Error checking mesh mentions: ${e.message}") 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) * Check if the content mentions the current user with @username format (simple, no hash suffix)
*/ */
private fun checkForMeshMention(content: String, currentNickname: String): Boolean { private fun checkForMeshMention(content: String, currentNickname: String): Boolean {
// Simple mention pattern for mesh: @username (no hash suffix like geohash) // Simple mention pattern for mesh: @username (no hash suffix like geohash)
val mentionPattern = "@([\\p{L}0-9_]+)".toRegex() val mentionPattern = "@([\\p{L}0-9_]+)".toRegex()
return mentionPattern.findAll(content).any { match -> return mentionPattern.findAll(content).any { match ->
val mentionedUsername = match.groupValues[1] val mentionedUsername = match.groupValues[1]
// Direct comparison for mesh mentions (no hash suffix to remove) // Direct comparison for mesh mentions (no hash suffix to remove)
@@ -12,6 +12,7 @@ import androidx.core.app.Person
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity import com.bitchat.android.MainActivity
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.util.NotificationIntervalManager
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -22,8 +23,13 @@ import java.util.concurrent.ConcurrentHashMap
* - Support for mention notifications in geohash chats * - Support for mention notifications in geohash chats
* - Support for first message notifications in geohash chats * - Support for first message notifications in geohash chats
* - Proper notification management and cleanup * - 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 { companion object {
private const val TAG = "NotificationManager" 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 NOTIFICATION_REQUEST_CODE = 1000
private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000 private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000
private const val SUMMARY_NOTIFICATION_ID = 999 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 // Intent extras for notification handling
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
const val EXTRA_OPEN_GEOHASH_CHAT = "open_geohash_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" const val EXTRA_GEOHASH = "geohash"
} }
private val notificationManager = NotificationManagerCompat.from(context)
private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Track pending notifications per sender to enable grouping // Track pending notifications per sender to enable grouping
private val pendingNotifications = ConcurrentHashMap<String, MutableList<PendingNotification>>() private val pendingNotifications = ConcurrentHashMap<String, MutableList<PendingNotification>>()
private val pendingGeohashNotifications = ConcurrentHashMap<String, MutableList<GeohashNotification>>() private val pendingGeohashNotifications = ConcurrentHashMap<String, MutableList<GeohashNotification>>()
// Track app background state // Track app background state
@Volatile @Volatile
private var isAppInBackground = false private var isAppInBackground = false
@@ -58,7 +65,7 @@ class NotificationManager(private val context: Context) {
// Track current view state // Track current view state
@Volatile @Volatile
private var currentPrivateChatPeer: String? = null private var currentPrivateChatPeer: String? = null
@Volatile @Volatile
private var currentGeohash: String? = null private var currentGeohash: String? = null
@@ -68,7 +75,7 @@ class NotificationManager(private val context: Context) {
val messageContent: String, val messageContent: String,
val timestamp: Long val timestamp: Long
) )
data class GeohashNotification( data class GeohashNotification(
val geohash: String, val geohash: String,
val senderNickname: String, val senderNickname: String,
@@ -94,7 +101,7 @@ class NotificationManager(private val context: Context) {
setShowBadge(true) setShowBadge(true)
} }
systemNotificationManager.createNotificationChannel(dmChannel) systemNotificationManager.createNotificationChannel(dmChannel)
// Geohash notifications channel // Geohash notifications channel
val geohashName = "Geohash Chats" val geohashName = "Geohash Chats"
val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels" val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels"
@@ -123,7 +130,7 @@ class NotificationManager(private val context: Context) {
currentPrivateChatPeer = peerID currentPrivateChatPeer = peerID
Log.d(TAG, "Current private chat peer changed: $peerID") Log.d(TAG, "Current private chat peer changed: $peerID")
} }
/** /**
* Update current geohash - affects notification logic for geohash chats * Update current geohash - affects notification logic for geohash chats
*/ */
@@ -165,6 +172,22 @@ class NotificationManager(private val context: Context) {
} }
} }
fun showActiveUserNotification(peers: List<String>) {
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) { private fun showNotificationForSender(senderPeerID: String) {
val notifications = pendingNotifications[senderPeerID] ?: return val notifications = pendingNotifications[senderPeerID] ?: return
if (notifications.isEmpty()) 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 // Use sender peer ID hash as notification ID to group messages from same sender
val notificationId = senderPeerID.hashCode() val notificationId = senderPeerID.hashCode()
notificationManager.notify(notificationId, builder.build()) notificationManager.notify(notificationId, builder.build())
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") 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() { private fun showSummaryNotification() {
if (pendingNotifications.isEmpty()) return if (pendingNotifications.isEmpty()) return
@@ -303,7 +361,7 @@ class NotificationManager(private val context: Context) {
builder.setStyle(style) builder.setStyle(style)
notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed summary notification for $senderCount senders") Log.d(TAG, "Displayed summary notification for $senderCount senders")
} }
@@ -316,7 +374,7 @@ class NotificationManager(private val context: Context) {
// Cancel the individual notification // Cancel the individual notification
val notificationId = senderPeerID.hashCode() val notificationId = senderPeerID.hashCode()
notificationManager.cancel(notificationId) notificationManager.cancel(notificationId)
// Update or remove summary notification // Update or remove summary notification
if (pendingNotifications.isEmpty()) { if (pendingNotifications.isEmpty()) {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID) 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 // Only show notifications if app is in background OR user is not viewing this specific geohash
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash) val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
if (!shouldNotify) { if (!shouldNotify) {
Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash") Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash")
return return
@@ -365,13 +423,13 @@ class NotificationManager(private val context: Context) {
// Create or update notification for this geohash // Create or update notification for this geohash
showNotificationForGeohash(geohash) showNotificationForGeohash(geohash)
// Update summary notification if we have multiple geohashes // Update summary notification if we have multiple geohashes
if (pendingGeohashNotifications.size > 1) { if (pendingGeohashNotifications.size > 1) {
showGeohashSummaryNotification() showGeohashSummaryNotification()
} }
} }
private fun showNotificationForGeohash(geohash: String) { private fun showNotificationForGeohash(geohash: String) {
val notifications = pendingGeohashNotifications[geohash] ?: return val notifications = pendingGeohashNotifications[geohash] ?: return
if (notifications.isEmpty()) return if (notifications.isEmpty()) return
@@ -402,7 +460,7 @@ class NotificationManager(private val context: Context) {
firstMessageCount > 0 -> "New activity in #$geohash" firstMessageCount > 0 -> "New activity in #$geohash"
else -> "Messages in #$geohash" else -> "Messages in #$geohash"
} }
val contentText = when { val contentText = when {
latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation" latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation"
@@ -429,7 +487,7 @@ class NotificationManager(private val context: Context) {
if (messageCount > 1) { if (messageCount > 1) {
val style = NotificationCompat.InboxStyle() val style = NotificationCompat.InboxStyle()
.setBigContentTitle(contentTitle) .setBigContentTitle(contentTitle)
// Show last few messages in expanded view // Show last few messages in expanded view
notifications.takeLast(5).forEach { notif -> notifications.takeLast(5).forEach { notif ->
val prefix = when { val prefix = when {
@@ -439,11 +497,11 @@ class NotificationManager(private val context: Context) {
} }
style.addLine("$prefix${notif.senderNickname}: ${notif.messageContent}") style.addLine("$prefix${notif.senderNickname}: ${notif.messageContent}")
} }
if (messageCount > 5) { if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more") style.setSummaryText("and ${messageCount - 5} more")
} }
builder.setStyle(style) builder.setStyle(style)
} else { } else {
// Single message - use BigTextStyle for long messages // 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 // Use geohash hash as notification ID to group messages from same geohash
val notificationId = 3000 + geohash.hashCode() val notificationId = 3000 + geohash.hashCode()
notificationManager.notify(notificationId, builder.build()) notificationManager.notify(notificationId, builder.build())
Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId") Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId")
} }
private fun showGeohashSummaryNotification() { private fun showGeohashSummaryNotification() {
if (pendingGeohashNotifications.isEmpty()) return if (pendingGeohashNotifications.isEmpty()) return
@@ -485,7 +543,7 @@ class NotificationManager(private val context: Context) {
} else { } else {
"bitchat - location chats" "bitchat - location chats"
} }
val contentText = "$totalMessages messages from $geohashCount locations" val contentText = "$totalMessages messages from $geohashCount locations"
val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID) val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID)
@@ -502,7 +560,7 @@ class NotificationManager(private val context: Context) {
// Add inbox style showing recent geohashes // Add inbox style showing recent geohashes
val style = NotificationCompat.InboxStyle() val style = NotificationCompat.InboxStyle()
.setBigContentTitle("New Location Messages") .setBigContentTitle("New Location Messages")
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) -> pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
val mentionCount = notifications.count { it.isMention } val mentionCount = notifications.count { it.isMention }
val messageCount = notifications.size val messageCount = notifications.size
@@ -513,15 +571,15 @@ class NotificationManager(private val context: Context) {
} }
style.addLine(line) style.addLine(line)
} }
if (pendingGeohashNotifications.size > 5) { if (pendingGeohashNotifications.size > 5) {
style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations") style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations")
} }
builder.setStyle(style) builder.setStyle(style)
notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build())
Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations") Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations")
} }
@@ -530,11 +588,11 @@ class NotificationManager(private val context: Context) {
*/ */
fun clearNotificationsForGeohash(geohash: String) { fun clearNotificationsForGeohash(geohash: String) {
pendingGeohashNotifications.remove(geohash) pendingGeohashNotifications.remove(geohash)
// Cancel the individual notification // Cancel the individual notification
val notificationId = 3000 + geohash.hashCode() val notificationId = 3000 + geohash.hashCode()
notificationManager.cancel(notificationId) notificationManager.cancel(notificationId)
// Update or remove summary notification // Update or remove summary notification
if (pendingGeohashNotifications.isEmpty()) { if (pendingGeohashNotifications.isEmpty()) {
notificationManager.cancel(GEOHASH_SUMMARY_NOTIFICATION_ID) notificationManager.cancel(GEOHASH_SUMMARY_NOTIFICATION_ID)
@@ -545,10 +603,10 @@ class NotificationManager(private val context: Context) {
// Update summary notification // Update summary notification
showGeohashSummaryNotification() showGeohashSummaryNotification()
} }
Log.d(TAG, "Cleared notifications for geohash: $geohash") Log.d(TAG, "Cleared notifications for geohash: $geohash")
} }
/** /**
* Show a notification for a mesh mention (@username format) * 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 // User is viewing mesh chat when: not in private chat AND not in geohash chat
val isViewingMeshChat = currentPrivateChatPeer == null && currentGeohash == null val isViewingMeshChat = currentPrivateChatPeer == null && currentGeohash == null
val shouldNotify = isAppInBackground || (!isAppInBackground && !isViewingMeshChat) val shouldNotify = isAppInBackground || (!isAppInBackground && !isViewingMeshChat)
if (!shouldNotify) { if (!shouldNotify) {
Log.d(TAG, "Skipping mesh mention notification - app in foreground and viewing mesh chat") Log.d(TAG, "Skipping mesh mention notification - app in foreground and viewing mesh chat")
return return
@@ -583,13 +641,13 @@ class NotificationManager(private val context: Context) {
// Create or update notification for mesh mentions // Create or update notification for mesh mentions
showNotificationForMeshMentions() showNotificationForMeshMentions()
// Update summary notification if we have multiple senders // Update summary notification if we have multiple senders
if (pendingNotifications.size > 1) { if (pendingNotifications.size > 1) {
showSummaryNotification() showSummaryNotification()
} }
} }
private fun showNotificationForMeshMentions() { private fun showNotificationForMeshMentions() {
val notifications = pendingNotifications["mesh_mentions"] ?: return val notifications = pendingNotifications["mesh_mentions"] ?: return
if (notifications.isEmpty()) return if (notifications.isEmpty()) return
@@ -616,7 +674,7 @@ class NotificationManager(private val context: Context) {
} else { } else {
"$messageCount mentions in Mesh Chat" "$messageCount mentions in Mesh Chat"
} }
val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}" val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -639,16 +697,16 @@ class NotificationManager(private val context: Context) {
if (messageCount > 1) { if (messageCount > 1) {
val style = NotificationCompat.InboxStyle() val style = NotificationCompat.InboxStyle()
.setBigContentTitle(contentTitle) .setBigContentTitle(contentTitle)
// Show last few messages in expanded view // Show last few messages in expanded view
notifications.takeLast(5).forEach { notif -> notifications.takeLast(5).forEach { notif ->
style.addLine("${notif.senderNickname}: ${notif.messageContent}") style.addLine("${notif.senderNickname}: ${notif.messageContent}")
} }
if (messageCount > 5) { if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more") style.setSummaryText("and ${messageCount - 5} more")
} }
builder.setStyle(style) builder.setStyle(style)
} else { } else {
// Single message - use BigTextStyle for long messages // 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 // Use a special notification ID for mesh mentions
val notificationId = 4000 // Different from DM and geohash IDs val notificationId = 4000 // Different from DM and geohash IDs
notificationManager.notify(notificationId, builder.build()) notificationManager.notify(notificationId, builder.build())
Log.d(TAG, "Displayed mesh mention notification: $contentTitle") Log.d(TAG, "Displayed mesh mention notification: $contentTitle")
} }
/** /**
* Clear mesh mention notifications (when user opens mesh chat) * Clear mesh mention notifications (when user opens mesh chat)
*/ */
fun clearMeshMentionNotifications() { fun clearMeshMentionNotifications() {
pendingNotifications.remove("mesh_mentions") pendingNotifications.remove("mesh_mentions")
// Cancel the mesh mention notification // Cancel the mesh mention notification
val notificationId = 4000 val notificationId = 4000
notificationManager.cancel(notificationId) notificationManager.cancel(notificationId)
// Update or remove summary notification // Update or remove summary notification
if (pendingNotifications.isEmpty()) { if (pendingNotifications.isEmpty()) {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID) notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
@@ -685,7 +743,7 @@ class NotificationManager(private val context: Context) {
// Update summary notification // Update summary notification
showSummaryNotification() showSummaryNotification()
} }
Log.d(TAG, "Cleared mesh mention notifications") Log.d(TAG, "Cleared mesh mention notifications")
} }
@@ -694,8 +752,8 @@ class NotificationManager(private val context: Context) {
*/ */
fun clearAllNotifications() { fun clearAllNotifications() {
pendingNotifications.clear() pendingNotifications.clear()
pendingGeohashNotifications.clear()
notificationManager.cancelAll() notificationManager.cancelAll()
pendingGeohashNotifications.clear()
Log.d(TAG, "Cleared all notifications") Log.d(TAG, "Cleared all notifications")
} }
@@ -703,7 +761,7 @@ class NotificationManager(private val context: Context) {
* Get pending notification count for UI badging * Get pending notification count for UI badging
*/ */
fun getPendingNotificationCount(): Int { fun getPendingNotificationCount(): Int {
return pendingNotifications.values.sumOf { it.size } + return pendingNotifications.values.sumOf { it.size } +
pendingGeohashNotifications.values.sumOf { it.size } pendingGeohashNotifications.values.sumOf { it.size }
} }
@@ -0,0 +1,13 @@
package com.bitchat.android.util
class NotificationIntervalManager {
private var _lastNetworkNotificationTime = 0L
val lastNetworkNotificationTime: Long
get() = _lastNetworkNotificationTime
val recentlySeenPeers: MutableSet<String> = mutableSetOf()
fun setLastNetworkNotificationTime(notificationTime: Long) {
_lastNetworkNotificationTime = notificationTime
}
}
@@ -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())
}
}
+10 -1
View File
@@ -47,6 +47,9 @@ security-crypto = "1.1.0-beta01"
junit = "4.13.2" junit = "4.13.2"
androidx-test-ext = "1.2.1" androidx-test-ext = "1.2.1"
espresso = "3.6.1" espresso = "3.6.1"
mockito-kotlin = "4.1.0"
mockito-inline = "4.1.0"
roboelectric = "4.15"
[libraries] [libraries]
# AndroidX Core # 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-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-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -135,7 +141,10 @@ cryptography = [
testing = [ testing = [
"junit", "junit",
"androidx-test-ext-junit", "androidx-test-ext-junit",
"androidx-test-espresso-core" "androidx-test-espresso-core",
"mockito-kotlin",
"mockito-inline",
"roboelectric"
] ]
compose-testing = [ compose-testing = [