mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 20:45:20 +00:00
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:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user