How Bitchat Android Optimizes Battery Usage with Duty Cycling: A Deep Dive into BLE Power Management
Bitchat Android optimizes battery usage with duty cycling by dynamically adjusting Bluetooth Low Energy (BLE) scan windows based on real-time power profiles, reducing scan activity from continuous operation to as little as 1 second ON and 29 seconds OFF when battery is low.
The permissionlesstech/bitchat-android repository implements a sophisticated, process-wide power management system that continuously monitors device conditions and adapts mesh networking activity accordingly. This adaptive approach ensures peer discovery remains possible while minimizing radio usage during unfavorable power states.
How Power Profiling Drives Duty Cycling
The foundation of Bitchat's battery optimization lies in continuous power profiling. The PowerManager class watches four key signals:
- Battery level and charging state
- App foreground/background status
- Whether the mesh already has direct peers
- Device thermal conditions
When any signal changes, PowerManager.refreshProfile() invokes PowerProfileResolver.resolve() to generate a RuntimePerformanceProfile. This profile contains a BleSchedule that specifies exact scan timing parameters.
// From PowerManager.kt - profile resolution triggers scheduling changes
fun refreshProfile() {
val newProfile = powerProfileResolver.resolve(
batteryLevel = batteryManager.getLevel(),
isCharging = batteryManager.isCharging(),
isBackground = appLifecycle.isInBackground(),
hasDirectPeers = meshState.getDirectPeerCount() > 0
)
// Apply the new duty cycle if changed
if (newProfile != currentProfile) {
bluetoothGattClientManager.applyPowerProfile(newProfile)
currentProfile = newProfile
}
}
The BleSchedule returned by the resolver contains three critical fields:
scanOnMs— duration to keep scanning activescanOffMs— duration to pause between scanscontinuousScan— flag to bypass duty cycling entirely
In performance mode with external power, scanOnMs is set to Long.MAX_VALUE for uninterrupted scanning. In low-power background mode, the resolver returns aggressive conservation values: 1 second ON, 29 seconds OFF.
Applying Dynamic Scan Schedules in BluetoothGattClientManager
The BluetoothGattClientManager class translates power profiles into executable scan behavior through applyPowerProfile(). This method serves as the control point for all duty cycling logic.
// From BluetoothGattClientManager.kt - core duty cycling implementation
fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) {
// Clear any existing duty cycle to prevent overlapping jobs
scanDutyCycleJob?.cancel()
scanDutyCycleJob = null
if (!isActive || !isClientRoleEnabled()) {
onScanStateChanged(false)
return
}
// Continuous scan mode: use watchdog only, no duty cycling
if (profile.ble.continuousScan) {
startScanWatchdog()
onScanStateChanged(true)
return
}
// Duty-cycled scanning: explicit ON/OFF pattern
stopScanWatchdog()
scanDutyCycleJob = connectionScope.launch {
while (isActive && isClientRoleEnabled()) {
onScanStateChanged(true) // BLE radio active
delay(profile.ble.scanOnMs) // wait ON duration
if (!isActive || !isClientRoleEnabled()) break
onScanStateChanged(false) // BLE radio sleeping
delay(profile.ble.scanOffMs) // wait OFF duration
}
}
}
The onScanStateChanged() callback bridges to the actual BLE stack operations:
onScanStateChanged(true)→startScanning()→ AndroidBluetoothLeScanner.startScan()onScanStateChanged(false)→stopScanning()→ AndroidBluetoothLeScanner.stopScan()
This explicit state management ensures the Android BLE adapter receives clean start/stop commands without race conditions.
Rate Limiting and Self-Healing Scan Recovery
Bitchat includes defensive mechanisms to prevent Android's "scanning too frequently" errors and to detect silently failed scanners.
The startScanning() method enforces a 5-second rate limit (scanRateLimit = 5000L) between scan attempts:
// From BluetoothGattClientManager.kt - rate-limited scan initiation
private fun startScanning() {
val now = System.currentTimeMillis()
if (now - lastScanStartTime < scanRateLimit) {
Log.d(TAG, "Rate limited: scan requested too soon")
return
}
lastScanStartTime = now
bluetoothLeScanner.startScan(scanCallback)
}
A watchdog coroutine monitors scan health through two timestamps:
lastScanStartTime— when scanning was last requestedlastScanResultTime— when the last device was actually discovered
If SCAN_STALE_RESULT_MS (120 seconds) elapses without results while scanning should be active, forceRestartScan() triggers a clean restart cycle. This self-healing behavior prevents the mesh from stalling due to BLE stack anomalies.
Power-Aware RSSI Filtering and Connection Limits
Duty cycling applies not just to discovery timing but to which peers are worth connecting.
Before any connection attempt, handleScanResult() validates signal strength against PowerManager.getRSSIThreshold():
// From BluetoothGattClientManager.kt - RSSI-based filtering
private fun handleScanResult(result: ScanResult) {
val rssi = result.rssi
// Skip weak signals when conserving power
if (rssi < powerManager.getRSSIThreshold()) {
Log.v(TAG, "Ignoring weak signal: $rssi dBm below threshold")
return
}
// Process only strong enough signals...
evaluatePeerForConnection(result)
}
In low-power profiles, this threshold rises to ignore marginal signals that would likely fail anyway, preventing wasted connection attempts.
The BleSchedule also carries maxConnections constraints. applyPowerProfile() extracts maxClient and maxOverall values to cap concurrent GATT connections, further constraining radio activity when battery prioritization is active.
Source Files and Architecture
| File | Responsibility |
|---|---|
app/src/main/java/com/bitchat/android/mesh/PowerManager.kt |
Power state monitoring, profile resolution, threshold queries |
app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt |
Duty cycle execution, scan lifecycle, watchdog, RSSI filtering |
PowerProfileResolver (internal to PowerManager.kt) |
Pure policy logic mapping conditions to BleSchedule values |
The architecture separates policy (what schedule to use) from mechanism (how to execute that schedule), enabling unit testing of power profile resolution without Bluetooth hardware.
Summary
- Dynamic profiling:
PowerManagercontinuously evaluates battery, charging, background state, and peer presence to select appropriate scan schedules - Adaptive duty cycling: Scan windows range from continuous (
Long.MAX_VALUE) to highly conservative (1s ON / 29s OFF) based on power profile - Defensive scanning: Rate limiting prevents Android API errors; watchdog detects and recovers from stalled scanners
- Signal-aware filtering: RSSI thresholds and connection limits prevent power waste on weak or excessive peer relationships
Frequently Asked Questions
How does Bitchat Android decide when to use continuous versus duty-cycled scanning?
The PowerProfileResolver returns continuousScan = true when the device is charging, in foreground, or explicitly configured for performance mode. Otherwise, it calculates scanOnMs and scanOffMs values that trade discovery speed for battery conservation. As implemented in permissionlesstech/bitchat-android, this decision happens in PowerManager.refreshProfile() and propagates through applyPowerProfile().
What prevents the duty cycle from causing missed peer discoveries?
The 1s/29s duty cycle represents a minimum viable scanning window. During the 1-second ON period, Android's BLE scanner receives all advertisements from nearby devices. The 29-second OFF period assumes that mesh peers transmit frequently enough to be caught in the next window. For critical scenarios, the watchdog's 120-second stale detection ensures scanning restarts if no devices appear.
Can developers customize the duty cycle values in Bitchat Android?
The duty cycle values are hardcoded in PowerProfileResolver based on internal testing. However, the architecture supports extension: modifying the resolver's logic or injecting a custom PowerProfileResolver implementation would allow different ON/OFF ratios. The BleSchedule data class cleanly encapsulates these timing parameters.
How does duty cycling interact with Android's own BLE scan throttling?
Android 7+ imposes system-level scan rate limits (typically 5 scans per 30 seconds). Bitchat's scanRateLimit = 5000L parameter stays within these bounds. The duty cycle OFF periods naturally space scan attempts, making the app respectful of both its own power goals and Android's platform constraints.
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 →