# How Bitchat Android Optimizes Battery Usage with Duty Cycling: A Deep Dive into BLE Power Management

> Discover how Bitchat Android optimizes battery usage with duty cycling. Learn how it dynamically adjusts BLE scan windows to conserve power, extending device life.

- Repository: [permissionlesstech/bitchat-android](https://github.com/permissionlesstech/bitchat-android)
- Tags: deep-dive
- Published: 2026-08-04

---

**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.

```kotlin
// 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 active
- `scanOffMs` — duration to pause between scans
- `continuousScan` — 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.

```kotlin
// 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()` → Android `BluetoothLeScanner.startScan()`
- `onScanStateChanged(false)` → `stopScanning()` → Android `BluetoothLeScanner.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:

```kotlin
// 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 requested
- `lastScanResultTime` — 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()`:

```kotlin
// 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`](https://github.com/permissionlesstech/bitchat-android/blob/main/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`](https://github.com/permissionlesstech/bitchat-android/blob/main/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt) | Duty cycle execution, scan lifecycle, watchdog, RSSI filtering |
| `PowerProfileResolver` (internal to [`PowerManager.kt`](https://github.com/permissionlesstech/bitchat-android/blob/main/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**: `PowerManager` continuously 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.