advertisingStateChanges: a stream ofBleAdvertisingStatereporting whether the radio is broadcasting our advertisement, and — when it is not because a start was refused — aBleAdvertiseFailureoftransientorterminalplus the controller's reason. A device that is not advertising still scans, still dials, and still looks healthy from the inside while no peer can discover it; this is the event that says so.
-
Android reads a refused advertise start by its error code instead of treating all five alike.
ADVERTISE_FAILED_ALREADY_STARTEDis a success — the radio is broadcasting our advertisement.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS(another app holds the controller's advertising sets) andADVERTISE_FAILED_INTERNAL_ERRORare transient: the advertiser and the open GATT server stay up, so a laterstartAdvertisingfinds the service registered and only has to reach the radio.ADVERTISE_FAILED_DATA_TOO_LARGE,ADVERTISE_FAILED_FEATURE_UNSUPPORTEDand any unnamed code tear the peripheral stack down. Both advertise callbacks — full start and beacon refresh — resolve a refusal the same way. -
linkSnapshot(): OS-level ground truth of live physical BLE links. Returns oneBleLinkInfoper distinct remote address with the GATT roles riding it (clientRole/serverRole). Android readsBluetoothManager's GATT + GATT_SERVER connected-device lists merged by address; iOS reads liveCBPeripheralstate plus tracked centrals merged by identifier. Lets consumers distinguish a shared over-ACL pair (one entry, both roles) from a dual-ACL pair (two entries for one peer).
- Android 8.x fatal binder NPE in
BluetoothGattServer.sendResponsewhen responding to a GATT client request after the peer dropped the link (the respond-after-disconnect race). All GATT server response sites now go through a crash-proof wrapper; a response to a departed peer is logged and discarded instead of killing the app.
- The fixed
grs-iosiOS platform marker. iOS peripherals no longer substitute the caller-suppliedlocalNamewithgrs-iosin the scan response — they advertise the caller'slocalNameif one is given, otherwise no local name at all. ThegrassrootsIosLocalNameconstant (exported from bothgrassroots_bluetooth_layerandgrassroots_bluetooth_layer_testing) is gone. This drops the pre-connection iOS-detection hint: consumers must now do dial-order arbitration without knowing a peer's platform until the signed post-connection ANNOUNCE identifies it. Breaking: code importinggrassrootsIosLocalNamewill no longer compile.
BleAdvertiseRequest.gattServiceUuid(optional, defaults toserviceUuid): decouples the advertised service UUID (a discovery/recognition beacon that may rotate) from the GATT service UUID the data characteristic is registered under (the stable data plane a connected central talks to).- Non-destructive re-advertise on both platforms: calling
startAdvertisingagain with a newserviceUuidbut the samegattServiceUuid+characteristicUuidrefreshes only the advertisement payload — the GATT server and every live peripheral link are preserved (iOS: re-issue advertisement, noremoveAllServices(); Android: restart only the advertiser, GATT server stays open). Previously any re-advertise tore down the GATT server and dropped all peripheral paths. Validated on hardware (Pixel/Android + iPhone/iOS): an established dual-role link survives repeated beacon rotations with live messaging throughout. - Peripheral
BlePath.serviceUuidnow reports the GATT service UUID (the service the central is actually connected to), not the advertised beacon.
- Android
startAdvertisingacquires the advertiser after the internal teardown (the teardown nulls it); previously a rebuild could log "Advertising start skipped: pending state is missing" and never start advertising.
BlePath.rssiandBlePayload.rssiare now nullable (int?/Long?/Int64?).nullrepresents "no measurement available" — previously the layer leaked a-100sentinel. Consumers that didpath.rssi.toLong()must add a null-safe operator.
- Central paths on both platforms now actively poll RSSI every 10 seconds so the value stays current after connection, not only at scan time. Android wires the poll into the per-path GATT op queue (started at
markCentralPathReady, usingreadRemoteRssi()); iOS uses a per-pathTimerdrivingCBPeripheral.readRSSI(). Both are torn down on disconnect / fail / dispose. decodeGattStatus(status: Int)translates Android GATT status codes (8, 19, 22, 62, 133, …) into human-readable names in everyonConnectionStateChangelog line.describeDisconnectError(_:)does the equivalent for iOS, decodingCBError.Code(connectionTimeout, peripheralDisconnected, connectionLimitReached, …) indidFailToConnectanddidDisconnectPeripherallogs.- Connection-state observability on both platforms: previous state, new state, decoded status, plus Android-side
pendingOpscount and in-flight op type, and iOS-side peripheraldidSubscribeTo/didUnsubscribeFrom(the only iOS signal that a remote central went away). grassrootsIosLocalName('grs-ios') constant, exported from bothgrassroots_bluetooth_layerandgrassroots_bluetooth_layer_testing. iOS peripherals now advertise this fixed local name in the scan response alongside the 128-bit service UUID, surfaced to scanners asBleAdvertisement.advertisedName. It is a platform marker (not an identity): peers use it to yield the first central dial to the iOS side, which can only open the first ACL link of a pair. Identity still travels exclusively in the signed post-connection ANNOUNCE.
- iOS advertising now carries the short fixed
grs-ioslocal name in addition to the service UUID; previously the local name was omitted entirely. The caller-suppliedlocalNameis still ignored on iOS (substituted with the marker) so the 128-bit service UUID stays in the primary advertise packet where external scanners can see it. Backgrounded iOS still drops the name and moves the UUID to the overflow area. - Central-connection diagnostics on iOS: added log lines at connect issue / already-connected, connection timeout (cancelling with no
didConnectin the window), anddidConnect(LL link up).
- iOS devices saw zero nearby Grassroots peers in BLE scans, which silently broke cross-platform discovery from the iOS side. The scan filter was rejecting every Grassroots advertisement because
CBUUID.uuidStringreturns dashed 128-bit UUIDs (84c40316-0871-e5ad-…) while callers pass compact-hex prefixes (84c403160871e5ad);hasPrefixtherefore always returned false. Both sides of the comparison are now normalized to dashless lowercase hex. - iOS
didDiscoverrejection log no longer floods the console with every nearby BLE device — only logs rejections whose advertised UUIDs at least look like Grassroots (prefix-matched), so genuine filter mismatches stay visible. - Scan results carrying non-real RSSI sentinel values are now dropped at the platform boundary on both Android and iOS, so consumers no longer receive placeholder measurements; each dropped result is logged for diagnostics.
- Removed a redundant
.pubignorethat shadowed.gitignoreduringflutter pub publish. It only re-listed.dart_tool/andpubspec.lock(both already in.gitignore) while disabling.gitignore's build-output exclusions, so the publish archive was bundlingbuild/artifacts (13 MB). With it gone, pub falls back to.gitignoreand the archive is 47 KB.
- Initial unified BLE central/peripheral plugin scaffold.
- Defines a Pigeon API centered on role-tagged BLE paths rather than stable device identities.
- Adds Android and iOS native central/peripheral implementations.
- Starts Android advertising only after the GATT service is confirmed.
- Cleans up Android advertising and scan failure state.
- Declares Android BLE permissions in the plugin manifest.
- Enforces a single default Dart facade instance for the global native callback channel.
- Documents iOS foreground/background advertising limits for cross-platform discovery.