A Flutter + native Android (Kotlin) app for finding nearby Bluetooth Low Energy devices, tracking one of them, and getting alerted when it goes out of range — with a real GATT connection to inspect a device's services and attempt to ring it, not just passive scanning.
This project demonstrates Flutter/Kotlin Platform Channel integration and real BLE GATT communication, following Clean Architecture end to end.
- Live BLE scanning — nearby devices listed in real time, sorted by signal strength, with an estimated distance (log-distance path-loss model) next to each RSSI reading.
- Manufacturer-name fallback — devices that don't advertise a name (common) show a friendlier "Apple Device"/"Google Device"/etc. label when the advertisement's manufacturer ID is recognized, instead of a bare "Unknown".
- Track a device — tap any device to track it; a banner shows its live distance and turns red once it's out of range.
- Out-of-range alerts — a local notification fires once a tracked device has stayed beyond the configured distance for a sustained period (avoids false alarms from one noisy reading), including detecting the case where the device stops advertising entirely.
- Configurable alert settings — the out-of-range distance and sustained-duration threshold are user-adjustable and persisted.
- Real GATT connection — long-press a device to connect to it, discover its actual Bluetooth services, and see friendly names for well-known ones (Battery Service, Device Information, etc.) alongside raw UUIDs for the rest.
- Ring Device — if a connected device exposes the standard Bluetooth SIG "Immediate Alert" service, you can trigger an alert on it. If it doesn't, the app says so plainly rather than pretending the button does something it can't.
- Handles the real edge cases — Bluetooth off before scanning, Bluetooth turned off while scanning, permission denied/permanently denied, no devices found, GATT connection failures/timeouts — each with its own clear UI state, not a generic error screen.
| Permission flow | Live scan list | Tracking banner |
| Real GATT services | Configurable alerts | Edge-case handling |
Clean Architecture on both sides of the Platform Channel boundary — the Dart side never talks to Android BLE APIs directly, and the Kotlin side never knows about Riverpod or widgets.
flowchart TB
subgraph Flutter["Flutter (Dart)"]
UI["Presentation<br/>ScanScreen · DeviceDetailScreen · SettingsScreen<br/>(Riverpod Notifiers)"]
DOM["Domain<br/>BleDevice · EstimateDistance · OutOfRangeDetector<br/>LookupServiceName · Repository interfaces"]
DATA["Data<br/>BleRepositoryImpl · GattRepositoryImpl · SettingsRepositoryImpl<br/>BleChannelDataSource"]
UI --> DOM
DATA --> DOM
end
subgraph Android["Android (Kotlin)"]
CH["MethodChannel + EventChannel"]
SCAN["BleScannerManager<br/>(BluetoothLeScanner, RSSI smoothing,<br/>Bluetooth-state BroadcastReceiver)"]
GATT["BleGattManager<br/>(BluetoothGatt connect/discover/write,<br/>main-thread-confined state)"]
CH --> SCAN
CH --> GATT
end
DATA -- "MethodChannel calls /<br/>EventChannel stream" --> CH
lib/
├── core/platform_channel/ # channel name constants
├── data/
│ ├── datasources/ # BleChannelDataSource (raw MethodChannel/EventChannel calls)
│ ├── repositories/ # BleRepositoryImpl, GattRepositoryImpl, SettingsRepositoryImpl
│ └── services/ # NotificationService
├── domain/
│ ├── entities/ # BleDevice, AlertSettings, GattServiceInfo
│ ├── repositories/ # repository interfaces
│ └── usecases/ # EstimateDistance, OutOfRangeDetector, LookupServiceName, ScanDevices
└── presentation/
├── providers/ # Riverpod state (ScanNotifier, DeviceDetailNotifier, ...)
├── screens/ # ScanScreen, DeviceDetailScreen, SettingsScreen, PermissionScreen
└── widgets/ # RadarPulse
android/app/src/main/kotlin/.../
├── ble/ # BleScannerManager, BleGattManager, BleDevice
└── channels/ # BleMethodChannel, BleEventChannel
| Layer | Technology |
|---|---|
| UI / state | Flutter (Dart), Riverpod |
| Native BLE | Kotlin — BluetoothLeScanner (scanning), BluetoothGatt (connect/discover/write) |
| Bridge | Flutter Platform Channels (MethodChannel + EventChannel) |
| Persistence | shared_preferences (alert settings) |
| Notifications | flutter_local_notifications |
| Permissions | permission_handler |
| Architecture | Clean Architecture (domain/data/presentation) |
Requires a physical Android device (API 21+) with Bluetooth LE — the emulator doesn't support real BLE hardware.
flutter pub get
flutter runGrant Bluetooth and location permissions when prompted (Android needs location permission pre-Android 12, and BLUETOOTH_SCAN/BLUETOOTH_CONNECT on Android 12+, to perform BLE scans).
flutter analyze
flutter testUnit tests cover the domain layer specifically — distance calculation, sustained out-of-range detection (with an injectable clock, so tests don't need to wait in real time), settings persistence, and GATT ring-outcome mapping (success/unsupported/failed) — the business logic that's most worth being able to explain and prove correct, rather than thin UI/plumbing code.
- Device names: most BLE devices don't advertise a name at all (this app does passive scanning, not a persistent GATT connection to every device) — the manufacturer-ID fallback helps but doesn't fully solve this.
- GATT connections to phones/laptops commonly fail (
CONNECT_FAILED, GATT status 133/147/8/19) — most modern devices refuse unsolicited connections from strangers. This is expected Android/BLE behavior, not a bug in this app; it's exactly why the app reports connection failures clearly instead of retrying silently. - Not yet built (tracked as stretch goals, not claimed as done): background scanning as a foreground service, tracking multiple devices simultaneously, a map view of a device's last known location.