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