fix: restore background peer availability alerts

This commit is contained in:
callebtc 2026-07-29 15:00:13 +02:00
parent 889c262edc
commit 363f8c5aae
18 changed files with 410 additions and 248 deletions

View File

@ -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")

View File

@ -127,16 +127,6 @@
tools:ignore="DataExtractionRules">
</service>
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
<receiver
android:name=".service.NotificationPermissionChangedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
</intent-filter>
</receiver>
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"

View File

@ -428,6 +428,8 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areRequiredPermissionsGranted()) {
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&

View File

@ -115,8 +115,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// 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)
)
// Service state management

View File

@ -85,9 +85,7 @@ class OnboardingCoordinator(
val missingRequired = permissionManager.getMissingPermissions()
// Optional permissions (ask, but do not block if denied)
val optionalToRequest = permissionManager
.getOptionalPermissions()
.filter { !permissionManager.isPermissionGranted(it) }
val optionalToRequest = permissionManager.getUnrequestedOptionalPermissions()
val missingPermissions = (missingRequired + optionalToRequest).distinct()
@ -101,6 +99,7 @@ class OnboardingCoordinator(
}
Log.d(TAG, "Requesting ${missingPermissions.size} permissions")
permissionManager.markOptionalPermissionsRequested(optionalToRequest)
permissionLauncher?.launch(missingPermissions.toTypedArray())
}
@ -115,7 +114,10 @@ class OnboardingCoordinator(
val allGranted = permissions.values.all { it }
val criticalPermissions = getCriticalPermissions()
val criticalGranted = criticalPermissions.all { permissions[it] == true }
// The launcher result only contains permissions requested in this round. Returning
// users may be asked for POST_NOTIFICATIONS alone, so re-check required permissions
// against package state instead of treating absent result-map entries as denials.
val criticalGranted = criticalPermissions.all(permissionManager::isPermissionGranted)
when {
criticalGranted -> {

View File

@ -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<String> {
return getOptionalPermissions().filter { permission ->
!isPermissionGranted(permission) &&
!sharedPrefs.getBoolean(optionalPermissionRequestKey(permission), false)
}
}
fun markOptionalPermissionsRequested(permissions: Collection<String>) {
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
*/

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -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(

View File

@ -109,7 +109,6 @@ class MeshDelegateHandler(
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
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) }

View File

@ -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<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
@ -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

View File

@ -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"
}

View File

@ -1,13 +0,0 @@
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
}
}

View File

@ -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

View File

@ -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())
}
}

View File

@ -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())
}
}

View File

@ -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"
}
}
}

View File

@ -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