mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
fix: restore background peer availability alerts
This commit is contained in:
parent
889c262edc
commit
363f8c5aae
@ -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")
|
||||
|
||||
@ -127,16 +127,6 @@
|
||||
tools:ignore="DataExtractionRules">
|
||||
</service>
|
||||
|
||||
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
|
||||
<receiver
|
||||
android:name=".service.NotificationPermissionChangedReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Auto-start mesh service after boot if enabled -->
|
||||
<receiver
|
||||
android:name=".service.BootCompletedReceiver"
|
||||
|
||||
@ -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() &&
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -13,7 +13,6 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.MainActivity
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@ -24,12 +23,10 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* - Support for mention notifications in geohash chats
|
||||
* - Support for first message notifications in geohash chats
|
||||
* - Proper notification management and cleanup
|
||||
* - Active peers notification
|
||||
*/
|
||||
class NotificationManager(
|
||||
private val context: Context,
|
||||
private val notificationManager: NotificationManagerCompat,
|
||||
private val notificationIntervalManager: NotificationIntervalManager
|
||||
private val notificationManager: NotificationManagerCompat
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@ -42,9 +39,6 @@ class NotificationManager(
|
||||
private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000
|
||||
private const val SUMMARY_NOTIFICATION_ID = 999
|
||||
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
|
||||
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
|
||||
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
|
||||
|
||||
// Intent extras for notification handling
|
||||
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
|
||||
const val EXTRA_OPEN_GEOHASH_CHAT = "open_geohash_chat"
|
||||
@ -176,22 +170,6 @@ class NotificationManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun showActiveUserNotification(peers: List<String>) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val activePeerNotificationIntervalExceeded =
|
||||
(currentTime - notificationIntervalManager.lastNetworkNotificationTime) > ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL
|
||||
val newPeers = peers - notificationIntervalManager.recentlySeenPeers
|
||||
if (isAppInBackground && activePeerNotificationIntervalExceeded && newPeers.isNotEmpty()) {
|
||||
Log.d(TAG, "Showing notification for active peers")
|
||||
showNotificationForActivePeers(peers.size)
|
||||
notificationIntervalManager.setLastNetworkNotificationTime(currentTime)
|
||||
notificationIntervalManager.recentlySeenPeers.addAll(newPeers)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping notification - app in foreground or it has been less than 5 minutes since last active peer notification")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationForSender(senderPeerID: String) {
|
||||
val notifications = pendingNotifications[senderPeerID] ?: return
|
||||
if (notifications.isEmpty()) return
|
||||
@ -314,41 +292,6 @@ class NotificationManager(
|
||||
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ACTIVE_PEERS_NOTIFICATION_ID,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// Build notification content
|
||||
val contentTitle = context.getString(R.string.notification_active_peers_title)
|
||||
val contentText = if (peersSize == 1) {
|
||||
context.getString(R.string.notification_active_peers_one)
|
||||
} else {
|
||||
context.getString(R.string.notification_active_peers_many, peersSize)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(contentTitle)
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build())
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID")
|
||||
}
|
||||
private fun showSummaryNotification() {
|
||||
if (pendingNotifications.isEmpty()) return
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user