From 363f8c5aaeebbbbc3ef7b634c12a1bd60f0f9cf0 Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Wed, 29 Jul 2026 15:00:13 +0200
Subject: [PATCH] fix: restore background peer availability alerts
---
.../android/testhook/TestHookDriver.kt | 16 ++
app/src/main/AndroidManifest.xml | 10 --
.../java/com/bitchat/android/MainActivity.kt | 2 +
.../android/mesh/BluetoothMeshService.kt | 3 +-
.../onboarding/OnboardingCoordinator.kt | 10 +-
.../android/onboarding/PermissionManager.kt | 28 ++++
.../android/service/MeshForegroundService.kt | 54 +++---
.../service/PeerAvailabilityNotifier.kt | 156 ++++++++++++++++++
.../com/bitchat/android/ui/ChatViewModel.kt | 4 +-
.../bitchat/android/ui/MeshDelegateHandler.kt | 1 -
.../bitchat/android/ui/NotificationManager.kt | 59 +------
.../com/bitchat/android/util/AppConstants.kt | 1 -
.../util/NotificationIntervalManager.kt | 13 --
.../wifi-aware/WifiAwareMeshService.kt | 3 +-
.../com/bitchat/NotificationManagerTest.kt | 117 -------------
.../OptionalPermissionRequestTest.kt | 53 ++++++
.../service/PeerAvailabilityNotifierTest.kt | 126 ++++++++++++++
.../MeshDelegateHandlerStateContractTest.kt | 2 -
18 files changed, 410 insertions(+), 248 deletions(-)
create mode 100644 app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt
delete mode 100644 app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt
delete mode 100644 app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt
create mode 100644 app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt
create mode 100644 app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt
diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
index a2e17b9b..a5146c34 100644
--- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
+++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
@@ -68,6 +68,7 @@ object TestHookDriver {
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
+ "inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
@@ -132,6 +133,21 @@ object TestHookDriver {
return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value))
}
+ /**
+ * Debug-only state injection for testing peer-list consumers such as notifications.
+ * A blank or missing comma-separated value restores the empty state.
+ */
+ private fun injectPeers(commaSeparatedPeers: String?): JSONObject {
+ val peers = commaSeparatedPeers
+ .orEmpty()
+ .split(',')
+ .map(String::trim)
+ .filter(String::isNotEmpty)
+ .distinct()
+ AppStateStore.setPeers(peers)
+ return ok("inject_peers").put("peers", JSONArray(peers))
+ }
+
private suspend fun connect(peerID: String, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c71b94c..a25287b0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -127,16 +127,6 @@
tools:ignore="DataExtractionRules">
-
-
-
-
-
-
-
{
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
index 23235068..8d61d10e 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
@@ -19,6 +19,8 @@ class PermissionManager(private val context: Context) {
private const val TAG = "PermissionManager"
private const val PREFS_NAME = "bitchat_permissions"
private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete"
+ private const val KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX =
+ "optional_permission_requested_"
}
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -149,6 +151,32 @@ class PermissionManager(private val context: Context) {
return optional
}
+ /**
+ * Optional permissions are prompted once. A denial must not trap returning users in
+ * onboarding, while users upgrading to a notification-permission Android version
+ * should still receive one contextual request.
+ */
+ fun getUnrequestedOptionalPermissions(): List {
+ return getOptionalPermissions().filter { permission ->
+ !isPermissionGranted(permission) &&
+ !sharedPrefs.getBoolean(optionalPermissionRequestKey(permission), false)
+ }
+ }
+
+ fun markOptionalPermissionsRequested(permissions: Collection) {
+ if (permissions.isEmpty()) return
+
+ sharedPrefs.edit().apply {
+ permissions.forEach { permission ->
+ putBoolean(optionalPermissionRequestKey(permission), true)
+ }
+ }.apply()
+ }
+
+ private fun optionalPermissionRequestKey(permission: String): String {
+ return KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX + permission
+ }
+
/**
* Check if a specific permission is granted
*/
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index 2f70bf84..9b475b60 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -13,6 +13,8 @@ import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
@@ -33,7 +35,6 @@ class MeshForegroundService : Service() {
const val ACTION_STOP = "com.bitchat.android.service.STOP"
const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
- const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
@@ -59,22 +60,6 @@ class MeshForegroundService : Service() {
}
}
- /**
- * Helper to be invoked right after POST_NOTIFICATIONS is granted to try
- * promoting/starting the foreground service immediately without polling.
- */
- fun onNotificationPermissionGranted(context: Context) {
- // If background is enabled and permission now granted, start/promo service
- if (!shouldStartAsForeground(context)) return
-
- val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
- }
-
fun stop(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
context.startService(intent)
@@ -82,8 +67,7 @@ class MeshForegroundService : Service() {
private fun shouldStartAsForeground(context: Context): Boolean {
return MeshServicePreferences.isBackgroundEnabled(true) &&
- hasBluetoothPermissionsStatic(context) &&
- hasNotificationPermissionStatic(context)
+ hasBluetoothPermissionsStatic(context)
}
private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
@@ -98,14 +82,10 @@ class MeshForegroundService : Service() {
}
}
- private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
- return if (Build.VERSION.SDK_INT >= 33) {
- androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
- } else true
- }
}
private lateinit var notificationManager: NotificationManagerCompat
+ private lateinit var peerAvailabilityNotifier: PeerAvailabilityNotifier
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
@@ -121,6 +101,7 @@ class MeshForegroundService : Service() {
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
+ peerAvailabilityNotifier = PeerAvailabilityNotifier(applicationContext)
createChannel()
// Ensure mesh service exists in holder (create if needed)
@@ -139,7 +120,14 @@ class MeshForegroundService : Service() {
com.bitchat.android.services.AppStateStore.peers
.map { peers -> peers.distinct().size }
.distinctUntilChanged()
- .collect {
+ .collect { peerCount ->
+ peerAvailabilityNotifier.onPeerCountChanged(
+ peerCount = peerCount,
+ isAppInBackground = !ProcessLifecycleOwner.get()
+ .lifecycle
+ .currentState
+ .isAtLeast(Lifecycle.State.STARTED)
+ )
if (isInForeground) updateNotification(force = false)
}
}
@@ -203,7 +191,9 @@ class MeshForegroundService : Service() {
// Ensure mesh is running (only after permissions are granted)
ensureMeshStarted()
- // Promote exactly once when eligible, otherwise stay background (or stop)
+ // Promote exactly once when eligible, otherwise stay background (or stop).
+ // POST_NOTIFICATIONS is intentionally not an eligibility requirement: Android 13+
+ // still allows foreground services and exposes them in the system task manager.
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
@@ -240,7 +230,7 @@ class MeshForegroundService : Service() {
val count = getUnifiedActivePeerCount()
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
if (lastNotifiedPeerCount != count) {
- notificationManager.notify(NOTIFICATION_ID, buildNotification(count))
+ startForegroundCompat(buildNotification(count))
lastNotifiedPeerCount = count
}
} else if (force) {
@@ -256,8 +246,8 @@ class MeshForegroundService : Service() {
// For starting FGS with connectedDevice|dataSync, we need:
// - Foreground service permissions (declared in manifest)
// - One of the device-related permissions (we request BL perms at runtime)
- // - On Android 13+, POST_NOTIFICATIONS to actually show notification
- return hasBluetoothPermissions() && hasNotificationPermission()
+ // POST_NOTIFICATIONS controls notification-drawer visibility, not FGS eligibility.
+ return hasBluetoothPermissions()
}
private fun getUnifiedActivePeerCount(): Int {
@@ -281,12 +271,6 @@ class MeshForegroundService : Service() {
}
}
- private fun hasNotificationPermission(): Boolean {
- return if (Build.VERSION.SDK_INT >= 33) {
- androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
- } else true
- }
-
private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
diff --git a/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt
new file mode 100644
index 00000000..de93bda8
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt
@@ -0,0 +1,156 @@
+package com.bitchat.android.service
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import com.bitchat.android.MainActivity
+import com.bitchat.android.R
+
+internal enum class PeerAvailabilityAction {
+ NONE,
+ SHOW,
+ CLEAR
+}
+
+/**
+ * Tracks mesh availability epochs. A notification is eligible only for a background
+ * transition from no peers to at least one peer. Returning to zero starts a new epoch.
+ */
+internal class PeerAvailabilityTracker {
+ private var previousPeerCount = 0
+
+ fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction {
+ require(peerCount >= 0) { "peerCount must not be negative" }
+
+ val action = when {
+ peerCount == 0 -> PeerAvailabilityAction.CLEAR
+ previousPeerCount == 0 && isAppInBackground -> PeerAvailabilityAction.SHOW
+ else -> PeerAvailabilityAction.NONE
+ }
+
+ previousPeerCount = peerCount
+ return action
+ }
+}
+
+internal interface PeerAvailabilityTextProvider {
+ fun title(): String
+ fun body(peerCount: Int): String
+}
+
+private class AndroidPeerAvailabilityTextProvider(
+ private val context: Context
+) : PeerAvailabilityTextProvider {
+ override fun title(): String {
+ return context.getString(R.string.notification_active_peers_title)
+ }
+
+ override fun body(peerCount: Int): String {
+ return if (peerCount == 1) {
+ context.getString(R.string.notification_active_peers_one)
+ } else {
+ context.getString(R.string.notification_active_peers_many, peerCount)
+ }
+ }
+}
+
+/**
+ * Owns the user-visible "bitchatters nearby" notification independently of the UI delegate.
+ */
+internal class PeerAvailabilityNotifier(
+ private val context: Context,
+ private val notificationManager: NotificationManagerCompat =
+ NotificationManagerCompat.from(context),
+ private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker(),
+ private val textProvider: PeerAvailabilityTextProvider =
+ AndroidPeerAvailabilityTextProvider(context),
+ private val canPostNotifications: () -> Boolean = {
+ notificationManager.areNotificationsEnabled() &&
+ (
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ )
+ }
+) {
+ companion object {
+ internal const val CHANNEL_ID = "bitchat_peer_availability_notifications"
+ internal const val NOTIFICATION_ID = 997
+ private const val TAG = "PeerAvailability"
+ }
+
+ init {
+ createNotificationChannel()
+ }
+
+ fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) {
+ when (tracker.update(peerCount, isAppInBackground)) {
+ PeerAvailabilityAction.NONE -> Unit
+ PeerAvailabilityAction.CLEAR -> notificationManager.cancel(NOTIFICATION_ID)
+ PeerAvailabilityAction.SHOW -> showNotification(peerCount)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ textProvider.title(),
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ enableVibration(true)
+ setShowBadge(false)
+ }
+ val systemManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ systemManager.createNotificationChannel(channel)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun showNotification(peerCount: Int) {
+ if (!canPostNotifications()) {
+ Log.i(TAG, "Skipping peer availability notification because notifications are disabled")
+ return
+ }
+
+ val openAppIntent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ NOTIFICATION_ID,
+ openAppIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(textProvider.title())
+ .setContentText(textProvider.body(peerCount))
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_SOCIAL)
+ .setShowWhen(true)
+ .setWhen(System.currentTimeMillis())
+ .build()
+
+ try {
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ Log.i(TAG, "Posted peer availability notification for $peerCount peer(s)")
+ } catch (error: SecurityException) {
+ Log.w(TAG, "Notification permission changed before peer alert was posted", error)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
index b72ce65c..ed75f6a2 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
@@ -27,7 +27,6 @@ import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import java.util.Date
import kotlin.random.Random
@@ -134,8 +133,7 @@ class ChatViewModel(
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager(
application.applicationContext,
- NotificationManagerCompat.from(application.applicationContext),
- NotificationIntervalManager()
+ NotificationManagerCompat.from(application.applicationContext)
)
private val verificationHandler = VerificationHandler(
diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
index de2f12c5..c6f7eb49 100644
--- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
@@ -109,7 +109,6 @@ class MeshDelegateHandler(
private suspend fun processPeerUpdate(mergedPeers: List) {
state.setConnectedPeers(mergedPeers)
state.setIsConnected(mergedPeers.isNotEmpty())
- notificationManager.showActiveUserNotification(mergedPeers)
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) }
diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt
index f15ada44..7f57289f 100644
--- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt
@@ -13,7 +13,6 @@ import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.services.ContactDirectory
-import com.bitchat.android.util.NotificationIntervalManager
import java.util.concurrent.ConcurrentHashMap
/**
@@ -24,12 +23,10 @@ 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,
- private val notificationManager: NotificationManagerCompat,
- private val notificationIntervalManager: NotificationIntervalManager
+ private val notificationManager: NotificationManagerCompat
) {
companion object {
@@ -42,9 +39,6 @@ class NotificationManager(
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 ACTIVE_PEERS_NOTIFICATION_ID = 997
- private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
-
// Intent extras for notification handling
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
const val EXTRA_OPEN_GEOHASH_CHAT = "open_geohash_chat"
@@ -176,22 +170,6 @@ class NotificationManager(
}
}
- fun showActiveUserNotification(peers: List) {
- val currentTime = System.currentTimeMillis()
- val activePeerNotificationIntervalExceeded =
- (currentTime - notificationIntervalManager.lastNetworkNotificationTime) > ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL
- val newPeers = peers - notificationIntervalManager.recentlySeenPeers
- if (isAppInBackground && activePeerNotificationIntervalExceeded && newPeers.isNotEmpty()) {
- Log.d(TAG, "Showing notification for active peers")
- showNotificationForActivePeers(peers.size)
- notificationIntervalManager.setLastNetworkNotificationTime(currentTime)
- notificationIntervalManager.recentlySeenPeers.addAll(newPeers)
- } else {
- Log.d(TAG, "Skipping notification - app in foreground or it has been less than 5 minutes since last active peer notification")
- return
- }
- }
-
private fun showNotificationForSender(senderPeerID: String) {
val notifications = pendingNotifications[senderPeerID] ?: return
if (notifications.isEmpty()) return
@@ -314,41 +292,6 @@ class NotificationManager(
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
}
- 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 = context.getString(R.string.notification_active_peers_title)
- val contentText = if (peersSize == 1) {
- context.getString(R.string.notification_active_peers_one)
- } else {
- context.getString(R.string.notification_active_peers_many, peersSize)
- }
-
- 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
diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt
index 0210f58c..b0f22596 100644
--- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt
+++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt
@@ -125,7 +125,6 @@ object AppConstants {
const val BASE_FONT_SIZE_SP: Int = 14
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
- const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
}
diff --git a/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt b/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt
deleted file mode 100644
index ead07448..00000000
--- a/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.bitchat.android.util
-
-class NotificationIntervalManager {
- private var _lastNetworkNotificationTime = 0L
- val lastNetworkNotificationTime: Long
- get() = _lastNetworkNotificationTime
-
- val recentlySeenPeers: MutableSet = mutableSetOf()
-
- fun setLastNetworkNotificationTime(notificationTime: Long) {
- _lastNetworkNotificationTime = notificationTime
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
index 479a1db6..f6f27a7f 100644
--- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
@@ -93,8 +93,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
- androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
- com.bitchat.android.util.NotificationIntervalManager()
+ androidx.core.app.NotificationManagerCompat.from(context.applicationContext)
)
// Wi-Fi Aware transport
diff --git a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt b/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt
deleted file mode 100644
index cfe8e5b4..00000000
--- a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-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.Ignore
-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
- private val notificationManagerCompat: NotificationManagerCompat = Mockito.mock(NotificationManagerCompat::class.java)
-
- @Before
- fun setup() {
- MockitoAnnotations.openMocks(this)
- notificationManager = NotificationManager(
- context,
- notificationManagerCompat,
- notificationIntervalManager
- )
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @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())
- }
-
- @Ignore // Temporarily disabled due to Mockito final class issues
- @Test
- fun `when an active peer is a new peer and there are already multiple recently seen peers, send notification`() {
- notificationManager.setAppBackgroundState(true)
- notificationIntervalManager.recentlySeenPeers.addAll(listOf("peer-1", "peer-2"))
- notificationManager.showActiveUserNotification(listOf("peer-3"))
- verify(notificationManagerCompat, times(1)).notify(any(), any())
- }
-}
diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt
new file mode 100644
index 00000000..b89a9a74
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt
@@ -0,0 +1,53 @@
+package com.bitchat.android.onboarding
+
+import android.Manifest
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class OptionalPermissionRequestTest {
+ private lateinit var application: Application
+
+ @Before
+ fun setUp() {
+ application = ApplicationProvider.getApplicationContext()
+ application.getSharedPreferences("bitchat_permissions", Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+ shadowOf(application).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ @Test
+ fun `missing notification permission is offered once`() {
+ val permissionManager = PermissionManager(application)
+
+ assertEquals(
+ listOf(Manifest.permission.POST_NOTIFICATIONS),
+ permissionManager.getUnrequestedOptionalPermissions()
+ )
+
+ permissionManager.markOptionalPermissionsRequested(
+ listOf(Manifest.permission.POST_NOTIFICATIONS)
+ )
+
+ assertTrue(permissionManager.getUnrequestedOptionalPermissions().isEmpty())
+ }
+
+ @Test
+ fun `granted notification permission is not requested`() {
+ shadowOf(application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
+
+ assertTrue(PermissionManager(application).getUnrequestedOptionalPermissions().isEmpty())
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt
new file mode 100644
index 00000000..68f4aa25
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt
@@ -0,0 +1,126 @@
+package com.bitchat.android.service
+
+import android.app.NotificationManager
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+class PeerAvailabilityTrackerTest {
+
+ @Test
+ fun `background zero to nonzero transition shows once per availability epoch`() {
+ val tracker = PeerAvailabilityTracker()
+
+ assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.NONE, tracker.update(2, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true))
+ }
+
+ @Test
+ fun `foreground discovery is not replayed after app enters background`() {
+ val tracker = PeerAvailabilityTracker()
+
+ assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = false))
+ assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true))
+ assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true))
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `negative peer count is rejected`() {
+ PeerAvailabilityTracker().update(-1, isAppInBackground = true)
+ }
+}
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [32])
+class PeerAvailabilityNotifierTest {
+ private lateinit var context: Context
+ private lateinit var systemNotificationManager: NotificationManager
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ systemNotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ systemNotificationManager.cancelAll()
+ }
+
+ @Test
+ fun `background availability posts on dedicated channel`() {
+ val textProvider = testTextProvider()
+ val notifier = PeerAvailabilityNotifier(
+ context = context,
+ textProvider = textProvider,
+ canPostNotifications = { true }
+ )
+
+ notifier.onPeerCountChanged(0, isAppInBackground = true)
+ notifier.onPeerCountChanged(2, isAppInBackground = true)
+
+ val notification =
+ shadowOf(systemNotificationManager).getNotification(PeerAvailabilityNotifier.NOTIFICATION_ID)
+ assertNotNull(notification)
+ assertEquals(PeerAvailabilityNotifier.CHANNEL_ID, notification.channelId)
+ assertEquals(
+ textProvider.title(),
+ notification.extras.getString("android.title")
+ )
+ assertEquals(
+ textProvider.body(2),
+ notification.extras.getString("android.text")
+ )
+ assertNotNull(
+ systemNotificationManager.getNotificationChannel(PeerAvailabilityNotifier.CHANNEL_ID)
+ )
+ }
+
+ @Test
+ fun `returning to zero cancels availability notification`() {
+ val notifier = PeerAvailabilityNotifier(
+ context = context,
+ textProvider = testTextProvider(),
+ canPostNotifications = { true }
+ )
+
+ notifier.onPeerCountChanged(1, isAppInBackground = true)
+ notifier.onPeerCountChanged(0, isAppInBackground = true)
+
+ assertNull(
+ shadowOf(systemNotificationManager).getNotification(PeerAvailabilityNotifier.NOTIFICATION_ID)
+ )
+ }
+
+ @Test
+ fun `disabled notifications do not post`() {
+ val notifier = PeerAvailabilityNotifier(
+ context = context,
+ textProvider = testTextProvider(),
+ canPostNotifications = { false }
+ )
+
+ notifier.onPeerCountChanged(1, isAppInBackground = true)
+
+ assertNull(
+ shadowOf(systemNotificationManager).getNotification(PeerAvailabilityNotifier.NOTIFICATION_ID)
+ )
+ }
+
+ private fun testTextProvider(): PeerAvailabilityTextProvider {
+ return object : PeerAvailabilityTextProvider {
+ override fun title(): String = "Bitchatters nearby"
+
+ override fun body(peerCount: Int): String = "$peerCount people around"
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt
index 162a5ef6..1bd4f61c 100644
--- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt
@@ -66,14 +66,12 @@ class MeshDelegateHandlerStateContractTest {
assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value)
assertTrue(state.isConnected.value)
- verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b"))
verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self")
handler.didUpdatePeerList(emptyList())
assertTrue(state.connectedPeers.value.isEmpty())
assertFalse(state.isConnected.value)
- verify(notifications).showActiveUserNotification(emptyList())
}
@Test