mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +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
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -11,10 +11,11 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.nostr.NostrGeohashService
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,12 @@ class ChatViewModel(
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(application.applicationContext)
|
||||
private val notificationManager = NotificationManager(
|
||||
application.applicationContext,
|
||||
NotificationManagerCompat.from(application.applicationContext),
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
state = state,
|
||||
@@ -330,8 +336,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
fun getPeerIDForNickname(nickname: String): String? {
|
||||
@@ -435,22 +439,6 @@ class ChatViewModel(
|
||||
state.setPeerRSSI(rssiValues)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
|
||||
fun getDebugStatus(): String {
|
||||
@@ -619,14 +607,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get participant count for a specific geohash (5-minute activity window)
|
||||
*/
|
||||
@@ -648,18 +628,6 @@ class ChatViewModel(
|
||||
nostrGeohashService.endGeohashSampling()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if a geohash person is teleported (iOS-compatible)
|
||||
*/
|
||||
@@ -676,10 +644,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
nostrGeohashService.selectLocationChannel(channel)
|
||||
}
|
||||
@@ -691,14 +655,6 @@ class ChatViewModel(
|
||||
nostrGeohashService.blockUserInGeohash(targetNickname)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Navigation Management
|
||||
|
||||
fun showAppInfo() {
|
||||
@@ -771,6 +727,4 @@ class ChatViewModel(
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Handles all BluetoothMeshDelegate callbacks and routes them to appropriate managers
|
||||
@@ -86,6 +85,7 @@ class MeshDelegateHandler(
|
||||
coroutineScope.launch {
|
||||
state.setConnectedPeers(peers)
|
||||
state.setIsConnected(peers.isNotEmpty())
|
||||
notificationManager.showActiveUserNotification(peers)
|
||||
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
|
||||
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.core.app.Person
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.MainActivity
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -22,8 +23,13 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* - Support for mention notifications in geohash chats
|
||||
* - Support for first message notifications in geohash chats
|
||||
* - Proper notification management and cleanup
|
||||
* - Active peers notification
|
||||
*/
|
||||
class NotificationManager(private val context: Context) {
|
||||
class NotificationManager(
|
||||
private val context: Context,
|
||||
private val notificationManager: NotificationManagerCompat,
|
||||
private val notificationIntervalManager: NotificationIntervalManager
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationManager"
|
||||
@@ -34,7 +40,9 @@ class NotificationManager(private val context: Context) {
|
||||
private const val NOTIFICATION_REQUEST_CODE = 1000
|
||||
private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000
|
||||
private const val SUMMARY_NOTIFICATION_ID = 999
|
||||
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
|
||||
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
|
||||
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
|
||||
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = 300_000L
|
||||
|
||||
// Intent extras for notification handling
|
||||
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
|
||||
@@ -44,7 +52,6 @@ class NotificationManager(private val context: Context) {
|
||||
const val EXTRA_GEOHASH = "geohash"
|
||||
}
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Track pending notifications per sender to enable grouping
|
||||
@@ -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) {
|
||||
val notifications = pendingNotifications[senderPeerID] ?: 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")
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ACTIVE_PEERS_NOTIFICATION_ID,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// Build notification content
|
||||
val contentTitle = "👥 bitchatters nearby!"
|
||||
val contentText = if (peersSize == 1) {
|
||||
"1 person around"
|
||||
} else {
|
||||
"$peersSize people around"
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(contentTitle)
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build())
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID")
|
||||
}
|
||||
private fun showSummaryNotification() {
|
||||
if (pendingNotifications.isEmpty()) return
|
||||
|
||||
@@ -694,8 +752,8 @@ class NotificationManager(private val context: Context) {
|
||||
*/
|
||||
fun clearAllNotifications() {
|
||||
pendingNotifications.clear()
|
||||
pendingGeohashNotifications.clear()
|
||||
notificationManager.cancelAll()
|
||||
pendingGeohashNotifications.clear()
|
||||
Log.d(TAG, "Cleared all notifications")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
androidx-test-ext = "1.2.1"
|
||||
espresso = "3.6.1"
|
||||
mockito-kotlin = "4.1.0"
|
||||
mockito-inline = "4.1.0"
|
||||
roboelectric = "4.15"
|
||||
|
||||
[libraries]
|
||||
# AndroidX Core
|
||||
@@ -103,6 +106,9 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a
|
||||
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" }
|
||||
mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" }
|
||||
roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"}
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@@ -135,7 +141,10 @@ cryptography = [
|
||||
testing = [
|
||||
"junit",
|
||||
"androidx-test-ext-junit",
|
||||
"androidx-test-espresso-core"
|
||||
"androidx-test-espresso-core",
|
||||
"mockito-kotlin",
|
||||
"mockito-inline",
|
||||
"roboelectric"
|
||||
]
|
||||
|
||||
compose-testing = [
|
||||
|
||||
Reference in New Issue
Block a user