How Bitchat Android Tracks Peer Availability in the Mesh Network
Bitchat Android uses a reactive state flow architecture where mesh services update a shared AppStateStore.peers list, a foreground service observes distinct peer counts, and a gated PeerAvailabilityNotifier decides when to show user notifications.
Bitchat Android is a permissionless, decentralized chat application that operates without internet connectivity by forming device-to-device mesh networks over Bluetooth Low Energy (BLE) and Wi-Fi Aware. Understanding how peer availability is tracked within this mesh is essential for developers building similar offline-first communication systems. The implementation relies on Kotlin coroutines, StateFlow for reactive state management, and carefully designed anti-flapping logic to prevent notification spam.
Core Architecture: From Mesh Discovery to UI Updates
The peer tracking system in Bitchat Android follows a unidirectional data flow pattern. Mesh transport layers detect peers, push updates to centralized state, a foreground service monitors changes, and a dedicated notifier gates user-visible alerts.
Step 1: Centralized Peer State in AppStateStore
All peer availability data converges at AppStateStore.peers, a MutableStateFlow<List<String>> that holds the current set of peer identifiers as strings[^1^].
// Located in: app/src/main/java/com/bitchat/android/services/AppStateStore.kt
object AppStateStore {
val peers: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
// ... other app-wide state flows
}
This singleton object acts as the single source of truth. Any component needing peer information—whether for UI rendering, connection management, or notifications—subscribes to this flow rather than accessing transport layers directly.
Step 2: Mesh Services Push Peer Updates
The UnifiedMeshService coordinates multiple transport implementations (BluetoothMeshService and WifiAwareMeshService). When any transport discovers or loses a peer, it updates AppStateStore.peers[^2^].
// Simplified pattern from mesh service implementations
fun onPeerDiscovered(peerId: String) {
val current = AppStateStore.peers.value.toMutableList()
if (!current.contains(peerId)) {
current.add(peerId)
AppStateStore.peers.value = current
}
}
This abstraction allows the notification system to remain agnostic of underlying transport mechanics—whether peers arrive via BLE advertisements or Wi-Fi Aware service discoveries, the state update path is identical.
Step 3: Foreground Service Observes Distinct Peer Count
The MeshForegroundService subscribes to a transformed stream of AppStateStore.peers and forwards changes to the notification subsystem[^3^].
// Located in: app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt (lines 129-136)
override fun onCreate() {
super.onCreate()
serviceScope.launch {
AppStateStore.peers
.map { it.distinct().size } // Count unique peers
.distinctUntilChanged() // Only emit on count changes
.collect { peerCount ->
peerAvailabilityNotifier.onPeerCountChanged(
peerCount = peerCount,
isAppInBackground = !ProcessLifecycleOwner.get()
.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
)
}
}
}
Key observations about this implementation:
distinct().sizehandles duplicate peer IDs that may appear from multiple transports discovering the same devicedistinctUntilChanged()eliminates redundant emissions when the count hasn't actually changed- Background detection uses
ProcessLifecycleOwnerto determine app visibility state
Anti-Flapping Notification Gating
Raw peer count changes would generate excessive notifications as devices move in and out of range. Bitchat Android implements a two-layer gating system in PeerAvailabilityNotifier and its inner PeerAvailabilityTracker class[^4^].
PeerAvailabilityTracker: Core State Machine
The tracker encapsulates all stateful gating logic at lines 87-123 of PeerAvailabilityNotifier.kt:
| Gate | Purpose | Threshold |
|---|---|---|
| Cooldown gate | Prevent repeated alerts | ALERT_COOLDOWN_MS = 5 minutes |
| Empty re-arm gate | Require mesh emptiness before new epoch | EMPTY_REARM_DELAY_MS = 30 seconds |
// Simplified structure from PeerAvailabilityTracker.update()
fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction {
val now = System.currentTimeMillis()
// Check cooldown: has enough time passed since last alert?
val cooldownElapsed = now - lastAlertTime > ALERT_COOLDOWN_MS
// Check empty re-arm: was mesh empty for sufficient duration?
val canRearm = peerCount == 0 && (now - emptySince) > EMPTY_REARM_DELAY_MS
return when {
peerCount > 0 && isAppInBackground && cooldownElapsed -> {
lastAlertTime = now
PeerAvailabilityAction.SHOW
}
canRearm -> {
// Reset state for new epoch
PeerAvailabilityAction.RESET
}
else -> PeerAvailabilityAction.NONE
}
}
Notification Aggregation Window
When the tracker returns SHOW, the notifier starts a 10-second aggregation window before actually posting the notification[^5^]:
// Located in: PeerAvailabilityNotifier.kt (lines 14-26)
private fun scheduleNotification() {
if (pendingNotificationJob?.isActive == true) return
pendingNotificationJob = scope.launch {
delay(AGGREGATION_WINDOW_MS) // 10 seconds
// Re-verify conditions at window completion
if (latestPeerCount > 0 && isAppCurrentlyInBackground()) {
showNotification(latestPeerCount)
}
}
}
This aggregation serves two purposes:
- Coalescing rapid changes — multiple peers arriving within 10 seconds produce one notification
- Final condition verification — the mesh state is re-checked before display, preventing stale alerts
Clearance on Mesh Empty State
When peer count drops to zero, PeerAvailabilityNotifier.clear() cancels pending jobs and removes existing notifications[^6^]:
// Located in: PeerAvailabilityNotifier.kt (lines 8-12)
fun clear() {
pendingNotificationJob?.cancel()
pendingNotificationJob = null
notificationManager.cancel(PEER_AVAILABILITY_NOTIFICATION_ID)
}
Complete Data Flow Diagram
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ BLE/Wi-Fi │────▶│ AppStateStore │────▶│ MeshForeground │
│ Mesh Services │ │ .peers (StateFlow) │ Service │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
┌─────────────────────┘
▼
┌───────────────────┐
│ PeerAvailability │
│ Notifier │
│ ┌───────────────┐ │
│ │ Tracker │ │
│ │ (gates) │ │
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ Aggregation │ │
│ │ (10s window) │ │
│ └───────────────┘ │
└─────────┬─────────┘
▼
┌───────────────────┐
│ System Notification │
│ "bitchatters nearby" │
└───────────────────┘
Configuration Constants
The anti-flapping behavior is controlled by three constants in PeerAvailabilityNotifier.kt[^7^]:
companion object {
const val AGGREGATION_WINDOW_MS = 10_000L // 10 seconds
const val ALERT_COOLDOWN_MS = 300_000L // 5 minutes
const val EMPTY_REARM_DELAY_MS = 30_000L // 30 seconds
}
These values represent trade-offs between user awareness and notification fatigue. The 5-minute cooldown ensures users aren't repeatedly alerted to the same persistent mesh, while the 30-second empty requirement prevents flapping when a single peer momentarily drops and rejoins.
Summary
AppStateStore.peersprovides centralized, reactive state for all peer identifiers in the meshMeshForegroundServicetransforms peer list changes into distinct count emissions and forwards them with background state contextPeerAvailabilityTrackerapplies cooldown and re-arm gates to prevent notification spam- 10-second aggregation window coalesces rapid peer changes and verifies final conditions before display
- Automatic clearance removes notifications when the mesh becomes empty, with state machine reset for future epochs
Frequently Asked Questions
How does Bitchat Android handle duplicate peer discoveries from multiple transports?
The distinct() operation on AppStateStore.peers ensures each unique peer ID is counted only once, regardless of whether it was discovered via BLE, Wi-Fi Aware, or both transports simultaneously. This deduplication happens in the flow transformation within MeshForegroundService.onCreate before any notification logic processes the count.
What triggers the "bitchatters nearby" notification to actually display?
Four conditions must all be satisfied: the peer count must be greater than zero, the app must be in the background, the 5-minute cooldown since the last alert must have elapsed, and the 10-second aggregation window must complete with the count still positive. The empty re-arm gate additionally requires the mesh to have been completely empty for 30 seconds before a new notification epoch can begin.
Where are the notification timing constants defined in the source code?
All three timing constants—AGGREGATION_WINDOW_MS (10 seconds), ALERT_COOLDOWN_MS (5 minutes), and EMPTY_REARM_DELAY_MS (30 seconds)—are defined as const val properties in the companion object of PeerAvailabilityNotifier.kt. These can be modified at compile time to adjust the trade-off between user awareness and notification frequency.
How does the system know when the app is in the background?
MeshForegroundService uses ProcessLifecycleOwner.get().lifecycle.currentState from the Android Architecture Components library. It checks isAtLeast(Lifecycle.State.STARTED)—when this returns false, the app is considered to be in the background and notifications are eligible for display.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →