The cooldown was tracked in two places. ApkRateLimitStore persisted it per scope and per route, and the ViewModel kept a second copy in downloadRetryAtMillis plus a retryAtMillis on Resumable and Error, frozen into WorkManager output data on the way through. The copy was the weaker of the two: it lived in memory, so a cold start lost it, and the deadline it froze belonged to whichever route earned it, which is exactly the drift the per-route store exists to prevent. It also could not expire on its own. downloadRetryBlocked was computed with System.currentTimeMillis() during composition, so nothing recomposed when the deadline passed; scheduleRetryUnlock papered over that with a viewModelScope delay that died with the process. Meanwhile the disabled row and icon gave the user no countdown to read, so a tap simply did nothing. Drops the copy. The store is consulted where the request is actually made and the UI stays enabled, which costs a worker that fails in well under a tenth of a second without touching the network. RateLimitedWithWait goes with it. Its "try again in %2$s min" was computed at failure time and baked into static text that never ticked down, so it was wrong within a minute; RateLimited says "try again later" and stays true. Removing it leaves nothing pre-formatted, so Resumable and Error now carry an ApkFailureMessage of string id plus arguments and the row resolves it during composition. Failure text follows the device locale rather than the locale the worker happened to run under. Anchors the GitHub cooldown at the moment it is judged. now was sampled before awaitRoute(), which can hold a request for the full 60-second route timeout, so a relative Retry-After interpreted against it could land in the past and let the very next check reach GitHub - the loop this branch set out to close. Reads the clock again once the route is ready and once the response arrives, and uses each where it applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bitchat for Android
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
This is the Android implementation of bitchat, fully protocol-compatible with the iOS version for cross-platform mesh communication.
See it in action
| Offline mesh conversation | Geohash globe picker |
|---|---|
![]() |
![]() |
License
This project is released into the public domain. See the LICENSE file for details.
Features
- Dual Transport Architecture: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
- Location-Based Channels: Geographic chat rooms using geohash coordinates over Nostr relays
- Intelligent Message Routing: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
- End-to-End Encryption: Noise Protocol (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
- Decentralized Mesh Network: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
- Wi-Fi Aware Transport: Higher-bandwidth local mesh on supported devices
- Channel Chats: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
- IRC-Style Commands: Familiar
/join,/msg,/whostyle interface - Tor Support: Built-in Tor (Arti) for private internet connectivity
- Emergency Wipe: Triple-tap to instantly clear all data
- Cross-Platform: Binary protocol compatible with bitchat on iOS and macOS
Technical Architecture
Bluetooth Mesh Network (Offline)
- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
- Compact binary packet format with fragmentation, TTL routing, and deduplication
- Adaptive duty cycling and connection limits for battery efficiency
- Foreground service keeps the mesh alive within Android background execution limits
Nostr Protocol (Internet)
- Global reach via public relays, geohash-based location channels
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
- Ephemeral keys per geohash area
Android Stack
- Kotlin, Jetpack Compose (Material 3), MVVM
- Coroutines and Flow for all networking and state
- Core components:
MeshForegroundService(persistent connectivity),BluetoothMeshService/WifiAwareMeshService(transports),UnifiedMeshService(transport selection),NoiseSessionManager(encryption sessions),MessageRouter(mesh/Nostr routing with outbox retry)
Building
Requires Android Studio and the Android SDK (API 26+).
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
./gradlew assembleDebug
Install on a connected device:
adb install -r app/build/outputs/apk/debug/app-debug.apk
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the pinned Linux container. Maintainers should follow the Android release guide. See Reproducible builds for the build trust model and public GitHub/Google Play verification procedures.
Testing
# Unit tests
./gradlew test
# Lint
./gradlew lint
# Instrumented tests (requires a device or emulator)
./gradlew connectedAndroidTest
Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.


