Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,89 @@ The app never checks its own version: it sends `APP-<x.y.z>` (store) or `APP-<ep
- **Airborne pause**: while the airborne latch is set, no fix is appended to the offline recording (`ApiQueueService.setOfflineRecordingPaused`); the session itself ends through the normal airborne block. See GPS & Zone Validation.
- **File**: `lib/services/offline_session_service.dart`

### Android Auto

MeshMapper's Map tab, on Android Auto head units. The car is a remote control
and status display for the session running on the phone: it never touches the
radio, GPS or API itself.

- **Native** (`android/app/src/main/kotlin/net/meshmapper/app/auto/`):
`MeshMapperCarAppService` (Car App Library, category `NAVIGATION`, the only
category allowed to draw its own map) opens `CarMapScreen`, a
NavigationTemplate laid out like the phone's Map tab:
- The map itself is drawn by `CarMapRenderer`: a MapLibre `MapView` in a
`Presentation` on a private `VirtualDisplay` backed by the car's Surface. It
draws the user's base style, the vector coverage overlay with the same
palette and opacity, the session's freshly patched cells on top, recently
heard repeaters, and the position arrow.
- The camera follows the car heading up by default, with the car placed
lower in the view, and keeps its last bearing while stopped. Panning stops
following. The centre button re-centres after a pan and otherwise switches
between heading up and north up.
- The status bar and counter chips become a status card drawn on the map at
the top left (`CarStatusText` builds its lines, including the phase
countdown, "Next disc 13s"). The phone header's noise floor and companion
battery become a box at the top right. Both are our own views on the
surface, not host templates, so their type size matches the phone.
- The right-hand map buttons become the map action strip (pan, zoom in,
zoom out, centre).
- The controls panel becomes the action strip (Start Passive or Stop, Ping,
a car-only map style cycle, and More for `DashboardScreen`, which has the
other start modes in `StartModeScreen` and the heard list in `HeardScreen`).

`CarStatusStore` is the process-wide meeting point, because the car can
connect before the phone app is opened and the activity can go away while
the car still shows MeshMapper. With no Flutter engine attached, the car
says "Open MeshMapper on your phone". Template screens redraw at most once a
second.
- **Channel** `meshmapper/android_auto` (`MeshMapperAndroidAutoService.kt` ⇄
`lib/services/android_auto/android_auto_bridge_service.dart`). Dart → native:
`attach` (returns whether a car is connected) and `publishSnapshot`. Native →
Dart: `carSessionChanged(bool)` and `command`. A command is an
`ExternalSessionCommand` map with source `androidAuto`, answered with an
`ExternalCommandCompletion` map that the car shows as a toast.
- **Snapshot**: `_buildAndroidAutoSnapshotMap` is the Siri projection without
the repeater catalog, capped at 12 heard rows, plus a `map` block built by
`AndroidAutoMapProjection`. That block holds position, style, the coverage
tile URL from the shared `coverageTileUrlTemplate`, palette, opacity, and the
newest 400 patch cells, plus a `header` block with the noise floor and its
colour from `PingColors`. Every rule is resolved in Dart so the car cannot
drift from the phone. It is published only while a car session is open.
`CarSnapshot.kt` parses it; keep its keys in step with
`siri_snapshot_models.dart` and `android_auto_map_projection.dart`.
- **Safety**: every tap goes through `admitExternalSessionCommand` and
`executeExternalSessionCommand`, the same gates as the phone's buttons, the
watch and Siri, so a stale car template can never transmit. Passive is the
one-tap Start; transmit modes need an extra screen.

**Testing.** Install the Desktop Head Unit (SDK Manager → SDK Tools → Android
Auto Desktop Head Unit Emulator). In the phone's Android Auto settings:
1. Tap the version number ten times to unlock developer settings.
2. Enable **Unknown sources**. Without it the car validator logs
`Package DENIED` for any build not installed from Play.
3. Turn on **Add new cars to Android Auto**, or the head unit is refused with
`car connection is not allowed`.
4. Tap **Start head unit server**.

Then run `adb forward tcp:5277 tcp:5277` and
`$ANDROID_HOME/extras/google/auto/desktop-head-unit`, keeping its stdin open,
because it exits on EOF. Debug builds accept any host
(`HostValidator.ALLOW_ALL_HOSTS_VALIDATOR`); release builds accept only the Car
App Library's allowlisted hosts. To capture the car screen,
`adb shell dumpsys SurfaceFlinger --display-id` lists
`com.google.android.projection.gearhead/…`; pass that id to
`adb exec-out screencap -p -d <id>`.

**Real cars.** A real head unit only lists apps installed from Play. A
sideloaded build is refused with `CAR.VALIDATOR: Package DENIED; failed all
other checks` even with Unknown sources on, while the Desktop Head Unit still
shows it. To test in a car, install through a Play internal testing track.

**Play Store.** Play only accepts real turn-by-turn apps in the navigation
category, so a public release of this car map needs that decision first. A Play-safe variant
would use the template-only POI category with a host-drawn map (pins, no
coverage overlay).

### Background Service

Keeps BLE and GPS active when the app is backgrounded during auto-ping.
Expand Down Expand Up @@ -2671,6 +2754,7 @@ debugError('[API] Failed to post batch: $error');
| `[BLE]` | Bluetooth connection and device communication |
| `[CONN]` | MeshCore connection protocol operations |
| `[SIRI]` | Siri App Intents bridge and snapshot publishing |
| `[CAR]` | Android Auto bridge: car session, snapshot publishing, car commands |
| `[EXTERNAL]` | External command execution (shared Siri/watch lane) |
| `[GPS]` | GPS/geolocation operations |
| `[PING]` | Ping sending and validation |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts**
- **Offline Mode** — Wardrive without connectivity, upload data later
- **30+ Device Models** — Automatic identification and power reporting for Ikoka, Heltec, RAK, LilyGo, Seeed, and more
- **Noise Floor Graphing** — Track signal quality over time
- **Android Auto** — The Map tab on the car screen with coverage, your position, a live status card, and Start/Stop/Ping controls
- **Dark/Light Theme** — System theme support

---
Expand All @@ -58,6 +59,7 @@ lib/
├── screens/ # Full-page UI screens
├── widgets/ # Reusable components
└── services/
├── android_auto/ # Android Auto bridge and car map projection
├── bluetooth/ # BLE abstraction layer
│ ├── bluetooth_service.dart # Abstract interface
│ ├── mobile_bluetooth.dart # Android/iOS (flutter_blue_plus)
Expand Down
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ dependencies {
// 4.6.2, but declared so MeshMapperAltitudeService.kt compiles against it.
// Version must match geolocator_android's transitive dependency.
implementation("com.google.android.gms:play-services-location:21.2.0")

// Android for Cars App Library, for the Android Auto screens in
// net.meshmapper.app.auto (CarMapScreen draws the map on the car surface).
implementation("androidx.car.app:app:1.4.0")
}
26 changes: 26 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Android Auto: the car home screen draws MeshMapper's own map on the
car display inside a NavigationTemplate (see auto/CarMapScreen.kt). -->
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES" />
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />

<application
android:label="MeshMapper"
android:name="${applicationName}"
Expand Down Expand Up @@ -84,6 +89,27 @@
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="net.meshmapper.app" />

<!-- Android Auto dashboard. The car mirrors the phone's session and
relays Start/Stop/Ping back through the same checks as the phone's
buttons; see net.meshmapper.app.auto. NAVIGATION is the only category
allowed to draw its own map, which is how the car shows the phone's
Map tab. Play only accepts real navigation apps there, so this is
for sideloaded builds with Android Auto's "Unknown sources" on. -->
<service
android:name=".auto.MeshMapperCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="2" />

<!-- Background service for continuous wardriving -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class MainActivity : FlutterActivity() {
private var usbService: MeshMapperUsbService? = null
private var networkService: MeshMapperNetworkService? = null
private var altitudeService: MeshMapperAltitudeService? = null
private var androidAutoService: MeshMapperAndroidAutoService? = null

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Expand All @@ -23,6 +24,8 @@ class MainActivity : FlutterActivity() {
networkService!!.configureFlutterEngine(flutterEngine)
altitudeService = MeshMapperAltitudeService(applicationContext)
altitudeService!!.configureFlutterEngine(flutterEngine)
androidAutoService = MeshMapperAndroidAutoService()
androidAutoService!!.configureFlutterEngine(flutterEngine)

// MapLibre tile cache management. Mirrors AppDelegate.swift's iOS
// implementation. Called from Dart's TileCacheService by the Offline
Expand Down Expand Up @@ -106,6 +109,7 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
usbService?.dispose()
networkService?.dispose()
androidAutoService?.dispose()
super.onDestroy()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package net.meshmapper.app

import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import net.meshmapper.app.auto.CarSnapshot
import net.meshmapper.app.auto.CarStatusStore

/// Flutter end of the Android Auto bridge (`meshmapper/android_auto`).
///
/// Dart calls `attach` once to learn whether a car is already connected and
/// `publishSnapshot` whenever the dashboard state changes. Native calls back
/// with `carSessionChanged` and `command` through [CarStatusStore], which owns
/// the channel reference so the car screens never touch the engine directly.
class MeshMapperAndroidAutoService {
private var channel: MethodChannel? = null

fun configureFlutterEngine(flutterEngine: FlutterEngine) {
val newChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"meshmapper/android_auto",
)
newChannel.setMethodCallHandler { call, result ->
when (call.method) {
"attach" -> result.success(CarStatusStore.carSessionActive)
"publishSnapshot" -> {
val snapshot = CarSnapshot.fromMap(call.arguments)
if (snapshot == null) {
result.error("bad_snapshot", "Snapshot was not a map", null)
} else {
CarStatusStore.updateSnapshot(snapshot)
result.success(null)
}
}
else -> result.notImplemented()
}
}
channel = newChannel
CarStatusStore.attachChannel(newChannel)
}

fun dispose() {
channel?.let {
it.setMethodCallHandler(null)
CarStatusStore.detachChannel(it)
}
channel = null
}
}
Loading