Merge pull request #816 from permissionlesstech/codex/fix-active-peer-notifications

Restore background peer availability alerts
This commit is contained in:
callebtc 2026-07-29 15:53:24 +02:00 committed by GitHub
commit 5b79da2db6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 682 additions and 278 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>
<receiver
android:name=".service.ConversationNotificationReceiver"
android:enabled="true"

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)
}
}
@ -156,11 +144,13 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
updateJob?.cancel()
updateJob = null
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
stopSelf()
return START_NOT_STICKY
@ -170,7 +160,7 @@ class MeshForegroundService : Service() {
updateJob?.cancel()
updateJob = null
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
@ -203,7 +193,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)
@ -234,30 +226,35 @@ class MeshForegroundService : Service() {
private fun updateNotification(force: Boolean) {
if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
return
}
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) {
// If disabled and forced, make sure to remove any prior foreground state
try { stopForeground(false) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
clearMeshNotifications()
isInForeground = false
lastNotifiedPeerCount = null
}
}
private fun clearMeshNotifications() {
notificationManager.cancel(NOTIFICATION_ID)
peerAvailabilityNotifier.clear()
}
private fun hasAllRequiredPermissions(): Boolean {
// 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 +278,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,249 @@
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 androidx.core.content.edit
import com.bitchat.android.MainActivity
import com.bitchat.android.R
internal enum class PeerAvailabilityAction {
NONE,
SHOW,
CLEAR
}
internal interface PeerAvailabilityAlertHistory {
var lastAlertAtMillis: Long?
}
internal class SharedPreferencesPeerAvailabilityAlertHistory(
context: Context
) : PeerAvailabilityAlertHistory {
companion object {
internal const val PREFERENCES_NAME = "peer_availability_notifications"
private const val KEY_LAST_ALERT_AT_MILLIS = "last_alert_at_millis"
}
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
override var lastAlertAtMillis: Long?
get() = if (preferences.contains(KEY_LAST_ALERT_AT_MILLIS)) {
preferences.getLong(KEY_LAST_ALERT_AT_MILLIS, 0L)
} else {
null
}
set(value) {
preferences.edit {
if (value == null) {
remove(KEY_LAST_ALERT_AT_MILLIS)
} else {
putLong(KEY_LAST_ALERT_AT_MILLIS, value)
}
}
}
}
/**
* Tracks mesh availability epochs with two anti-flapping gates:
* - no more than one alert per persisted cooldown window;
* - after an alert, the mesh must remain empty before another epoch can re-arm.
*/
internal class PeerAvailabilityTracker(
private val alertHistory: PeerAvailabilityAlertHistory,
private val nowMillis: () -> Long = System::currentTimeMillis,
private val alertCooldownMs: Long = ALERT_COOLDOWN_MS,
private val emptyRearmDelayMs: Long = EMPTY_REARM_DELAY_MS
) {
companion object {
internal const val ALERT_COOLDOWN_MS = 5 * 60_000L
internal const val EMPTY_REARM_DELAY_MS = 30_000L
}
private var previousPeerCount = 0
private var isArmed = true
private var emptySinceMillis: Long? = null
init {
require(alertCooldownMs >= 0) { "alertCooldownMs must not be negative" }
require(emptyRearmDelayMs >= 0) { "emptyRearmDelayMs must not be negative" }
}
fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction {
require(peerCount >= 0) { "peerCount must not be negative" }
val now = nowMillis()
if (peerCount == 0) {
if (previousPeerCount > 0 || emptySinceMillis == null) {
emptySinceMillis = now
}
previousPeerCount = 0
return PeerAvailabilityAction.CLEAR
}
val transitionedFromEmpty = previousPeerCount == 0
previousPeerCount = peerCount
if (!transitionedFromEmpty) return PeerAvailabilityAction.NONE
if (!isArmed) {
val emptySince = emptySinceMillis
val remainedEmptyLongEnough =
emptySince != null && now - emptySince >= emptyRearmDelayMs
if (!remainedEmptyLongEnough) {
emptySinceMillis = null
return PeerAvailabilityAction.NONE
}
isArmed = true
}
emptySinceMillis = null
val lastAlertAt = alertHistory.lastAlertAtMillis
val cooldownElapsed =
lastAlertAt == null || now - lastAlertAt >= alertCooldownMs
return if (isAppInBackground && cooldownElapsed) {
PeerAvailabilityAction.SHOW
} else {
PeerAvailabilityAction.NONE
}
}
/**
* Called only after NotificationManager accepts the post. Failed or disabled posts
* do not consume the cooldown or require the mesh to re-arm.
*/
fun markAlertShown() {
alertHistory.lastAlertAtMillis = nowMillis()
isArmed = false
}
}
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(
SharedPreferencesPeerAvailabilityAlertHistory(context)
),
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 -> clear()
PeerAvailabilityAction.SHOW -> showNotification(peerCount)
}
}
fun clear() {
notificationManager.cancel(NOTIFICATION_ID)
}
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)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build()
try {
notificationManager.notify(NOTIFICATION_ID, notification)
tracker.markAlertShown()
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

@ -30,7 +30,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
@ -152,8 +151,7 @@ class ChatViewModel(
)
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

@ -23,7 +23,6 @@ import com.bitchat.android.R
import com.bitchat.android.service.ConversationNotificationReceiver
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ConversationListPreferences
import com.bitchat.android.util.NotificationIntervalManager
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
@ -36,12 +35,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 {
@ -54,9 +51,7 @@ 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 MAX_MESSAGES_IN_NOTIFICATION = 25
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"
@ -220,22 +215,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
@ -430,41 +409,6 @@ class NotificationManager(
)
}
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())
notifySafely(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

@ -46,7 +46,7 @@
<string name="notification_summary_more">و%1$d إضافية</string>
<string name="notification_messages_from_people">%1$d رسالة من %2$d أشخاص</string>
<string name="notification_more_conversations">و%1$d محادثات أخرى</string>
<string name="notification_active_peers_title">👥 مستخدمون قريبون!</string>
<string name="notification_active_peers_title">مستخدمون قريبون!</string>
<string name="notification_active_peers_one">شخص واحد قريب</string>
<string name="notification_active_peers_many">%1$d أشخاص قريبون</string>
<string name="notification_new_messages">رسائل جديدة</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">এবং %1$d আরো</string>
<string name="notification_messages_from_people">%2$d জন থেকে %1$d বার্তা</string>
<string name="notification_more_conversations">এবং %1$d আরো কথোপকথন</string>
<string name="notification_active_peers_title">👥 কাছাকাছি bitchatter!</string>
<string name="notification_active_peers_title">কাছাকাছি bitchatter!</string>
<string name="notification_active_peers_one">কাছাকাছি ১ জন</string>
<string name="notification_active_peers_many">কাছাকাছি %1$d জন</string>
<string name="notification_new_messages">নতুন বার্তা</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">und %1$d weitere</string>
<string name="notification_messages_from_people">%1$d Nachrichten von %2$d Personen</string>
<string name="notification_more_conversations">und %1$d weitere Unterhaltungen</string>
<string name="notification_active_peers_title">👥 bitchatter in der Nähe!</string>
<string name="notification_active_peers_title">bitchatter in der Nähe!</string>
<string name="notification_active_peers_one">1 Person in der Nähe</string>
<string name="notification_active_peers_many">%1$d Personen in der Nähe</string>
<string name="notification_new_messages">Neue Nachrichten</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">y %1$d más</string>
<string name="notification_messages_from_people">%1$d mensajes de %2$d personas</string>
<string name="notification_more_conversations">y %1$d conversaciones más</string>
<string name="notification_active_peers_title">👥 ¡bitchatters cerca!</string>
<string name="notification_active_peers_title">¡bitchatters cerca!</string>
<string name="notification_active_peers_one">1 persona cerca</string>
<string name="notification_active_peers_many">%1$d personas cerca</string>
<string name="notification_new_messages">Nuevos mensajes</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">و %1$d مورد دیگر</string>
<string name="notification_messages_from_people">%1$d پیام از %2$d نفر</string>
<string name="notification_more_conversations">و %1$d گفت‌وگوی دیگر</string>
<string name="notification_active_peers_title">👥 کاربران bitchat در نزدیکی!</string>
<string name="notification_active_peers_title">کاربران bitchat در نزدیکی!</string>
<string name="notification_active_peers_one">۱ نفر در نزدیکی</string>
<string name="notification_active_peers_many">%1$d نفر در نزدیکی</string>
<string name="notification_new_messages">پیام‌های جدید</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">at %1$d pa</string>
<string name="notification_messages_from_people">%1$d mensahe mula sa %2$d tao</string>
<string name="notification_more_conversations">at %1$d pang usapan</string>
<string name="notification_active_peers_title">👥 mga bitchatter na malapit!</string>
<string name="notification_active_peers_title">mga bitchatter na malapit!</string>
<string name="notification_active_peers_one">1 tao sa paligid</string>
<string name="notification_active_peers_many">%1$d tao sa paligid</string>
<string name="notification_new_messages">Mga bagong mensahe</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">et %1$d de plus</string>
<string name="notification_messages_from_people">%1$d messages de %2$d personnes</string>
<string name="notification_more_conversations">et %1$d conversations de plus</string>
<string name="notification_active_peers_title">👥 bitchatters à proximité !</string>
<string name="notification_active_peers_title">bitchatters à proximité !</string>
<string name="notification_active_peers_one">1 personne à proximité</string>
<string name="notification_active_peers_many">%1$d personnes à proximité</string>
<string name="notification_new_messages">Nouveaux messages</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">और %1$d</string>
<string name="notification_messages_from_people">%2$d लोगों से %1$d संदेश</string>
<string name="notification_more_conversations">और %1$d वार्तालाप</string>
<string name="notification_active_peers_title">👥 पास में उपयोगकर्ता!</string>
<string name="notification_active_peers_title">पास में उपयोगकर्ता!</string>
<string name="notification_active_peers_one">पास में 1 व्यक्ति</string>
<string name="notification_active_peers_many">पास में %1$d लोग</string>
<string name="notification_new_messages">नए संदेश</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">dan %1$d lagi</string>
<string name="notification_messages_from_people">%1$d pesan dari %2$d orang</string>
<string name="notification_more_conversations">dan %1$d percakapan lagi</string>
<string name="notification_active_peers_title">👥 bitchatter terdekat!</string>
<string name="notification_active_peers_title">bitchatter terdekat!</string>
<string name="notification_active_peers_one">1 orang terdekat</string>
<string name="notification_active_peers_many">%1$d orang terdekat</string>
<string name="notification_new_messages">Pesan baru</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">e altri %1$d</string>
<string name="notification_messages_from_people">%1$d messaggi da %2$d persone</string>
<string name="notification_more_conversations">e altre %1$d conversazioni</string>
<string name="notification_active_peers_title">👥 bitchatter nelle vicinanze!</string>
<string name="notification_active_peers_title">bitchatter nelle vicinanze!</string>
<string name="notification_active_peers_one">1 persona nei dintorni</string>
<string name="notification_active_peers_many">%1$d persone nei dintorni</string>
<string name="notification_new_messages">Nuovi messaggi</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">ほか %1$d 件</string>
<string name="notification_messages_from_people">%2$d 人からの %1$d 件のメッセージ</string>
<string name="notification_more_conversations">ほか %1$d 会話</string>
<string name="notification_active_peers_title">👥 近くに bitchatter </string>
<string name="notification_active_peers_title">近くに bitchatter </string>
<string name="notification_active_peers_one">近くに 1 人</string>
<string name="notification_active_peers_many">近くに %1$d 人</string>
<string name="notification_new_messages">新着メッセージ</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">და კიდევ %1$d</string>
<string name="notification_messages_from_people">%2$d ადამიანიდან %1$d შეტყობინება</string>
<string name="notification_more_conversations">და კიდევ %1$d საუბარი</string>
<string name="notification_active_peers_title">👥 bitchatter-ები ახლოს!</string>
<string name="notification_active_peers_title">bitchatter-ები ახლოს!</string>
<string name="notification_active_peers_one">1 ადამიანი ახლოს</string>
<string name="notification_active_peers_many">ახლოს %1$d ადამიანი</string>
<string name="notification_new_messages">ახალი შეტყობინებები</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">그리고 %1$d개 더</string>
<string name="notification_messages_from_people">%2$d명에게서 %1$d개의 메시지</string>
<string name="notification_more_conversations">그리고 %1$d개의 대화 더</string>
<string name="notification_active_peers_title">👥 근처 bitchatter!</string>
<string name="notification_active_peers_title">근처 bitchatter!</string>
<string name="notification_active_peers_one">근처 1명</string>
<string name="notification_active_peers_many">근처 %1$d명</string>
<string name="notification_new_messages">새 메시지</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">ary %1$d hafa</string>
<string name="notification_messages_from_people">%1$d hafatra avy amin\'ny olona %2$d</string>
<string name="notification_more_conversations">ary %1$d resaka hafa</string>
<string name="notification_active_peers_title">👥 bitchatter akaiky!</string>
<string name="notification_active_peers_title">bitchatter akaiky!</string>
<string name="notification_active_peers_one">Olona 1 manodidina</string>
<string name="notification_active_peers_many">Olona %1$d manodidina</string>
<string name="notification_new_messages">Hafatra vaovao</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">र %1$d थप</string>
<string name="notification_messages_from_people">%1$d सन्देश %2$d जनाबाट</string>
<string name="notification_more_conversations">र %1$d अरू कुराकानी</string>
<string name="notification_active_peers_title">👥 नजिकै bitchatter हरू!</string>
<string name="notification_active_peers_title">नजिकै bitchatter हरू!</string>
<string name="notification_active_peers_one">१ जना नजिकै</string>
<string name="notification_active_peers_many">%1$d जना नजिकै</string>
<string name="notification_new_messages">नयाँ सन्देश</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">en %1$d meer</string>
<string name="notification_messages_from_people">%1$d berichten van %2$d personen</string>
<string name="notification_more_conversations">en %1$d meer conversaties</string>
<string name="notification_active_peers_title">👥 bitchatters in de buurt!</string>
<string name="notification_active_peers_title">bitchatters in de buurt!</string>
<string name="notification_active_peers_one">1 persoon in de buurt</string>
<string name="notification_active_peers_many">%1$d personen in de buurt</string>
<string name="notification_new_messages">Nieuwe berichten</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">تے %1$d ہور</string>
<string name="notification_messages_from_people">%2$d لوکان کولوں %1$d پیغام</string>
<string name="notification_more_conversations">تے %1$d ہور گلاں</string>
<string name="notification_active_peers_title">👥 نیڑے bitchatter!</string>
<string name="notification_active_peers_title">نیڑے bitchatter!</string>
<string name="notification_active_peers_one">نیڑے 1 بندہ</string>
<string name="notification_active_peers_many">نیڑے %1$d بندے</string>
<string name="notification_new_messages">نویں پیغام</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">e mais %1$d</string>
<string name="notification_messages_from_people">%1$d mensagens de %2$d pessoas</string>
<string name="notification_more_conversations">e mais %1$d conversas</string>
<string name="notification_active_peers_title">👥 pessoas do bitchat por perto!</string>
<string name="notification_active_peers_title">pessoas do bitchat por perto!</string>
<string name="notification_active_peers_one">1 pessoa por perto</string>
<string name="notification_active_peers_many">%1$d pessoas por perto</string>
<string name="notification_new_messages">Novas mensagens</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">e mais %1$d</string>
<string name="notification_messages_from_people">%1$d mensagens de %2$d pessoas</string>
<string name="notification_more_conversations">e mais %1$d conversas</string>
<string name="notification_active_peers_title">👥 bitchatters por perto!</string>
<string name="notification_active_peers_title">bitchatters por perto!</string>
<string name="notification_active_peers_one">1 pessoa por perto</string>
<string name="notification_active_peers_many">%1$d pessoas por perto</string>
<string name="notification_new_messages">Novas mensagens</string>

View File

@ -40,7 +40,7 @@
<string name="notification_summary_more">и ещё %1$d</string>
<string name="notification_messages_from_people">%1$d сообщений от %2$d людей</string>
<string name="notification_more_conversations">и ещё %1$d бесед</string>
<string name="notification_active_peers_title">👥 bitchatter рядом!</string>
<string name="notification_active_peers_title">bitchatter рядом!</string>
<string name="notification_active_peers_one">Рядом 1 человек</string>
<string name="notification_active_peers_many">Рядом %1$d человек</string>
<string name="notification_new_messages">Новые сообщения</string>

View File

@ -40,7 +40,7 @@
<string name="notification_summary_more">och %1$d till</string>
<string name="notification_messages_from_people">%1$d meddelanden från %2$d personer</string>
<string name="notification_more_conversations">och %1$d fler konversationer</string>
<string name="notification_active_peers_title">👥 bitchatter i närheten!</string>
<string name="notification_active_peers_title">bitchatter i närheten!</string>
<string name="notification_active_peers_one">1 person i närheten</string>
<string name="notification_active_peers_many">%1$d personer i närheten</string>
<string name="notification_new_messages">Nya meddelanden</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">และอีก %1$d</string>
<string name="notification_messages_from_people">ข้อความ %1$d จาก %2$d คน</string>
<string name="notification_more_conversations">และการสนทนาอีก %1$d รายการ</string>
<string name="notification_active_peers_title">👥 มีผู้ใช้ใกล้คุณ!</string>
<string name="notification_active_peers_title">มีผู้ใช้ใกล้คุณ!</string>
<string name="notification_active_peers_one">มี 1 คนใกล้คุณ</string>
<string name="notification_active_peers_many">มี %1$d คนใกล้คุณ</string>
<string name="notification_new_messages">ข้อความใหม่</string>

View File

@ -40,7 +40,7 @@
<string name="notification_summary_more">ve %1$d daha</string>
<string name="notification_messages_from_people">%2$d kişiden %1$d mesaj</string>
<string name="notification_more_conversations">ve %1$d sohbet daha</string>
<string name="notification_active_peers_title">👥 yakında bitchatterlar!</string>
<string name="notification_active_peers_title">yakında bitchatterlar!</string>
<string name="notification_active_peers_one">Yakında 1 kişi</string>
<string name="notification_active_peers_many">Yakında %1$d kişi</string>
<string name="notification_new_messages">Yeni mesajlar</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">اور %1$d مزید</string>
<string name="notification_messages_from_people">%2$d لوگوں سے %1$d پیغامات</string>
<string name="notification_more_conversations">اور %1$d مزید گفتگو</string>
<string name="notification_active_peers_title">👥 قریب میں bitchatter!</string>
<string name="notification_active_peers_title">قریب میں bitchatter!</string>
<string name="notification_active_peers_one">قریب میں 1 شخص</string>
<string name="notification_active_peers_many">قریب میں %1$d لوگ</string>
<string name="notification_new_messages">نئے پیغامات</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">và %1$d khác</string>
<string name="notification_messages_from_people">%1$d tin nhắn từ %2$d người</string>
<string name="notification_more_conversations">và %1$d cuộc trò chuyện khác</string>
<string name="notification_active_peers_title">👥 bitchatter gần đó!</string>
<string name="notification_active_peers_title">bitchatter gần đó!</string>
<string name="notification_active_peers_one">1 người gần đó</string>
<string name="notification_active_peers_many">%1$d người gần đó</string>
<string name="notification_new_messages">Tin nhắn mới</string>

View File

@ -46,7 +46,7 @@
<string name="notification_summary_more">以及另外 %1$d 条</string>
<string name="notification_messages_from_people">来自 %2$d 位的 %1$d 条消息</string>
<string name="notification_more_conversations">以及另外 %1$d 个会话</string>
<string name="notification_active_peers_title">👥 附近的 bitchatter</string>
<string name="notification_active_peers_title">附近的 bitchatter</string>
<string name="notification_active_peers_one">附近 1 人</string>
<string name="notification_active_peers_many">附近 %1$d 人</string>
<string name="notification_new_messages">新消息</string>

View File

@ -42,7 +42,7 @@
<string name="notification_summary_more">and %1$d more</string>
<string name="notification_messages_from_people">%1$d messages from %2$d people</string>
<string name="notification_more_conversations">and %1$d more conversations</string>
<string name="notification_active_peers_title">👥 bitchatters nearby!</string>
<string name="notification_active_peers_title">bitchatters nearby!</string>
<string name="notification_active_peers_one">1 person around</string>
<string name="notification_active_peers_many">%1$d people around</string>
<string name="notification_new_messages">New Messages</string>

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,267 @@
package com.bitchat.android.service
import android.app.Notification
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.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
class PeerAvailabilityTrackerTest {
@Test
fun `repeated flapping produces only one alert during cooldown`() {
val clock = MutableClock()
val tracker = tracker(clock = clock)
var showCount = 0
if (tracker.update(1, isAppInBackground = true) == PeerAvailabilityAction.SHOW) {
showCount++
tracker.markAlertShown()
}
repeat(5) {
clock.advance(1_000L)
assertEquals(
PeerAvailabilityAction.CLEAR,
tracker.update(0, isAppInBackground = true)
)
clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS)
if (tracker.update(1, isAppInBackground = true) == PeerAvailabilityAction.SHOW) {
showCount++
tracker.markAlertShown()
}
}
assertEquals(1, showCount)
}
@Test
fun `mesh must remain empty before notification re-arms`() {
val clock = MutableClock()
val tracker = tracker(
clock = clock,
alertCooldownMs = 0L
)
assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true))
tracker.markAlertShown()
assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true))
clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS - 1)
assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = true))
assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true))
clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS)
assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true))
}
@Test
fun `cooldown survives tracker recreation`() {
val clock = MutableClock()
val history = InMemoryAlertHistory()
val originalTracker = tracker(clock = clock, history = history)
assertEquals(
PeerAvailabilityAction.SHOW,
originalTracker.update(1, isAppInBackground = true)
)
originalTracker.markAlertShown()
clock.advance(PeerAvailabilityTracker.ALERT_COOLDOWN_MS - 1)
val restartedDuringCooldown = tracker(clock = clock, history = history)
assertEquals(
PeerAvailabilityAction.NONE,
restartedDuringCooldown.update(1, isAppInBackground = true)
)
clock.advance(1)
val restartedAfterCooldown = tracker(clock = clock, history = history)
assertEquals(
PeerAvailabilityAction.SHOW,
restartedAfterCooldown.update(1, isAppInBackground = true)
)
}
@Test
fun `foreground discovery is not replayed after app enters background`() {
val tracker = tracker()
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`() {
tracker().update(-1, isAppInBackground = true)
}
private fun tracker(
clock: MutableClock = MutableClock(),
history: InMemoryAlertHistory = InMemoryAlertHistory(),
alertCooldownMs: Long = PeerAvailabilityTracker.ALERT_COOLDOWN_MS
): PeerAvailabilityTracker {
return PeerAvailabilityTracker(
alertHistory = history,
nowMillis = clock::now,
alertCooldownMs = alertCooldownMs
)
}
}
@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()
context.getSharedPreferences(
SharedPreferencesPeerAvailabilityAlertHistory.PREFERENCES_NAME,
Context.MODE_PRIVATE
).edit().clear().commit()
}
@Test
fun `background availability posts on dedicated channel`() {
val textProvider = testTextProvider()
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
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")
)
assertTrue(notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0)
assertNotNull(
systemNotificationManager.getNotificationChannel(PeerAvailabilityNotifier.CHANNEL_ID)
)
}
@Test
fun `returning to zero cancels availability notification`() {
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
textProvider = testTextProvider(),
canPostNotifications = { true }
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
notifier.onPeerCountChanged(0, isAppInBackground = true)
assertNull(
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
)
}
@Test
fun `explicit clear cancels availability notification`() {
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
textProvider = testTextProvider(),
canPostNotifications = { true }
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
notifier.clear()
assertNull(
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
)
}
@Test
fun `disabled notifications do not post`() {
val history = InMemoryAlertHistory()
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = PeerAvailabilityTracker(history),
textProvider = testTextProvider(),
canPostNotifications = { false }
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
assertNull(
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
)
assertNull(history.lastAlertAtMillis)
}
@Test
fun `shared preferences history persists last alert time`() {
val history = SharedPreferencesPeerAvailabilityAlertHistory(context)
history.lastAlertAtMillis = 42L
assertEquals(
42L,
SharedPreferencesPeerAvailabilityAlertHistory(context).lastAlertAtMillis ?: -1L
)
}
private fun tracker(): PeerAvailabilityTracker {
return PeerAvailabilityTracker(InMemoryAlertHistory())
}
private fun testTextProvider(): PeerAvailabilityTextProvider {
return object : PeerAvailabilityTextProvider {
override fun title(): String = "Bitchatters nearby"
override fun body(peerCount: Int): String = "$peerCount people around"
}
}
}
private class InMemoryAlertHistory(
override var lastAlertAtMillis: Long? = null
) : PeerAvailabilityAlertHistory
private class MutableClock(
private var currentMillis: Long = 1_000L
) {
fun now(): Long = currentMillis
fun advance(durationMillis: Long) {
currentMillis += durationMillis
}
}

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