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,
@@ -330,8 +336,6 @@ class ChatViewModel(
} }
} }
// MARK: - Utility Functions // MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? { fun getPeerIDForNickname(nickname: String): String? {
@@ -435,22 +439,6 @@ class ChatViewModel(
state.setPeerRSSI(rssiValues) state.setPeerRSSI(rssiValues)
} }
// MARK: - Debug and Troubleshooting // MARK: - Debug and Troubleshooting
fun getDebugStatus(): String { fun getDebugStatus(): String {
@@ -619,14 +607,6 @@ class ChatViewModel(
} }
} }
/** /**
* Get participant count for a specific geohash (5-minute activity window) * Get participant count for a specific geohash (5-minute activity window)
*/ */
@@ -648,18 +628,6 @@ class ChatViewModel(
nostrGeohashService.endGeohashSampling() nostrGeohashService.endGeohashSampling()
} }
/** /**
* Check if a geohash person is teleported (iOS-compatible) * Check if a geohash person is teleported (iOS-compatible)
*/ */
@@ -676,10 +644,6 @@ class ChatViewModel(
} }
} }
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
nostrGeohashService.selectLocationChannel(channel) nostrGeohashService.selectLocationChannel(channel)
} }
@@ -691,14 +655,6 @@ class ChatViewModel(
nostrGeohashService.blockUserInGeohash(targetNickname) nostrGeohashService.blockUserInGeohash(targetNickname)
} }
// MARK: - Navigation Management // MARK: - Navigation Management
fun showAppInfo() { fun showAppInfo() {
@@ -771,6 +727,4 @@ class ChatViewModel(
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
@@ -86,6 +85,7 @@ 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) }
@@ -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,7 +40,9 @@ 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"
@@ -44,7 +52,6 @@ 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
@@ -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
@@ -253,6 +276,41 @@ class NotificationManager(private val context: Context) {
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
@@ -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")
} }
@@ -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 = [