Skip to content

Commit e05f3ba

Browse files
v1.5.4+311: fix iPad-host-cannot-be-seen-by-iPhone-joiner regression
Two interacting bugs caused this: Bug A (primary, blocks ID-based / scan-tap join entirely): The v1.5.4+306 audit added a hard null-guard on device.sessionId in session_join_card.dart that showed a 'host did not broadcast its session id' snackbar and aborted the join when sessionId was null. Combined with the v1.5.4+308 SIGABRT hotfix that removed CBAdvertisementDataServiceDataKey from iOS advertisements (Apple crashes when 3rd-party apps try to set it), iOS hosts no longer broadcast their sessionId via BLE — so iPhone joiners ALWAYS hit the snackbar and could never join an iPad host. v1.5.2 worked precisely because it didn't have this guard. Fix: fall back to device.id when sessionId is null. SyncEngine doesn't gate by sessionId, so a mismatched local-DB session label is cosmetic, not a sync break. Bug B (secondary, broke iPad→iPhone direction even after a successful QR-based join): BleTransport.broadcast()'s peripheral-mode path passed the entire SyncPayload to pm.updateValue(...) in one call with no MTU-aware chunking. iOS silently truncates anything past central.maximumUpdateValueLength. A 150-250 byte position payload became 20 bytes of garbled JSON on the iPhone, SyncPayload.fromBytes threw, the message was dropped — iPad showed 1/8 connected, iPhone showed 0/8. Fix: chunk via the existing 0x01/0x02/0x03 protocol; surface central.maximumUpdateValueLength from BleAdvertiserChannel.swift didSubscribeTo so Dart knows the per- central limit; use the MIN across subscribed centrals so a single chunked stream is compatible with every subscriber; retry up to 3x with 25ms delay on pm.updateValue=false (transmit-queue-full). Plus three MPC bug fixes flagged by the parallel audit but not the primary bug: - Info.plist NSBonjourServices was missing _red-grid-link._udp; iOS 14+ silently rejects MPC service registration without both _tcp and _udp. Added _udp. - IosP2pTransport._handlePeerFound now extracts sessionId from discoveryInfo['sessionId'] (the native side already packs it, but Dart was discarding it). Without this, MPC-discovered iPads also fell into Bug A's null-guard. - IosP2pTransport now wires the 'onError' event so MPC advertiser/ browser failures surface on the transport state stream instead of being silently swallowed (which masked Bug B for weeks).
1 parent ed93c61 commit e05f3ba

6 files changed

Lines changed: 229 additions & 45 deletions

File tree

ios/Runner/BleAdvertiserChannel.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,19 @@ extension BleAdvertiserChannel: CBPeripheralManagerDelegate {
415415
func peripheralManager(_ peripheral: CBPeripheralManager,
416416
central: CBCentral,
417417
didSubscribeTo characteristic: CBCharacteristic) {
418-
os_log("CENTRAL SUBSCRIBED: %{public}@ to %{public}@", log: log, type: .error,
419-
central.identifier.uuidString, characteristic.uuid.uuidString)
418+
// CRITICAL (v1.5.4+311): include `central.maximumUpdateValueLength`
419+
// in the event so the Dart side can chunk notify payloads that
420+
// exceed the negotiated ATT MTU. Default ATT MTU is 23 (20 byte
421+
// payload). A typical Field Link position payload is 150–250
422+
// bytes; without chunking, iOS silently truncates the rest and
423+
// the central receives garbled JSON that fails to decode (the
424+
// root cause of the iPad-as-host → iPhone-as-joiner regression
425+
// surfaced in v1.5.4+307…+310 TestFlight reports).
426+
let maxUpdateLen = central.maximumUpdateValueLength
427+
os_log("CENTRAL SUBSCRIBED: %{public}@ to %{public}@ maxUpdateLen=%d",
428+
log: log, type: .error,
429+
central.identifier.uuidString, characteristic.uuid.uuidString,
430+
maxUpdateLen)
420431

421432
if !subscribedCentrals.contains(where: { $0.identifier == central.identifier }) {
422433
subscribedCentrals.append(central)
@@ -425,6 +436,7 @@ extension BleAdvertiserChannel: CBPeripheralManagerDelegate {
425436
sendEvent("onCentralSubscribed", data: [
426437
"centralId": central.identifier.uuidString,
427438
"characteristicUuid": characteristic.uuid.uuidString,
439+
"maxUpdateLength": maxUpdateLen,
428440
])
429441
}
430442

ios/Runner/Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<key>NSBonjourServices</key>
3636
<array>
3737
<string>_red-grid-link._tcp</string>
38+
<string>_red-grid-link._udp</string>
3839
</array>
3940
<key>NSCameraUsageDescription</key>
4041
<string>Red Grid Link uses the camera to scan QR codes for quick Field Link session joining.</string>

lib/services/field_link/transport/ble_transport.dart

Lines changed: 162 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ class BleTransport implements TransportService {
9292
/// GATT server, not the other way around.
9393
final Set<String> _peripheralCentrals = {};
9494

95+
/// Per-central `maximumUpdateValueLength` — the max bytes we can
96+
/// notify in one `pm.updateValue:forCharacteristic:onSubscribedCentrals:`
97+
/// call before iOS silently truncates. Surfaced from the native side via
98+
/// the `onCentralSubscribed` event. When broadcasting, we use the MIN of
99+
/// these so a single chunked stream is compatible with every subscriber.
100+
/// (v1.5.4+311 fix for the iPad-as-host → iPhone-as-joiner regression.)
101+
final Map<String, int> _peripheralCentralMaxUpdateLength = {};
102+
95103
/// Maps CoreBluetooth central UUIDs (from peripheral mode) to the peer's
96104
/// logical device ID (from their first SyncPayload.senderId). CoreBluetooth
97105
/// identifies centrals by their system UUID which is different from the
@@ -749,25 +757,61 @@ class BleTransport implements TransportService {
749757
// Send to peripheral-mode connections (centrals that connected to our
750758
// GATT server). Push data via ble_peripheral's updateCharacteristic which
751759
// triggers a characteristic notification on the central side.
760+
//
761+
// CRITICAL (v1.5.4+311): chunk the payload to fit each subscribed
762+
// central's `maximumUpdateValueLength`. Prior to this fix the entire
763+
// payload was shipped in one `pm.updateValue` call. iOS silently
764+
// truncates anything past the central's MTU - 3, and the iPhone
765+
// receive side (`_onCharacteristicValueReceived`) saw garbled bytes
766+
// that failed JSON parse and dropped silently. Symptom: iPhone
767+
// joiner showed 0/8 connected because the iPad host's heartbeat
768+
// never decoded successfully on the iPhone — even though the
769+
// notification was delivered.
770+
//
771+
// Default ATT MTU is 23 (20-byte payload). A position SyncPayload
772+
// is 150-250 bytes, so under default MTU the receiver got 19 of
773+
// those bytes — never a complete JSON object. Chunking with the
774+
// existing 0x01/0x02/0x03 protocol fixes this end-to-end.
752775
if (_peripheralCentrals.isNotEmpty) {
753776
try {
754-
// Prepend the "complete" chunk flag (0x00) so the receiver's
755-
// _onCharacteristicValueReceived correctly identifies this as a
756-
// non-chunked message.
757-
final packet = Uint8List(1 + data.length);
758-
packet[0] = 0x00; // complete (non-chunked)
759-
packet.setRange(1, packet.length, data);
760-
761-
// Send notification to all subscribed centrals via platform channel.
762-
try {
763-
await _advertiserChannel.invokeMethod<void>('updateValue', {
764-
'characteristicUuid': BleConstants.positionCharUuid,
765-
'data': packet,
766-
});
767-
} catch (e) {
768-
if (kDebugMode) print('[BleTransport] peripheral updateValue failed: $e');
777+
// Use the smallest known per-central max-update-length so a
778+
// single chunked stream is compatible with every subscriber.
779+
// Default to BleConstants.minMtu - ATT_HEADER (20) when we
780+
// haven't received maxUpdateLength yet (e.g. on Android side
781+
// where we don't surface it). 20 - 2 (chunk header) = 18 bytes
782+
// payload per chunk in the worst case — slow but correct.
783+
final maxUpdateLen = _peripheralCentralMaxUpdateLength.values.isEmpty
784+
? (BleConstants.minMtu - _attOverhead)
785+
: _peripheralCentralMaxUpdateLength.values
786+
.reduce((a, b) => a < b ? a : b);
787+
final maxChunkPayload = maxUpdateLen - 2; // 2 = chunk header bytes
788+
789+
if (data.length <= maxChunkPayload) {
790+
// Fits in a single notification — emit with the "complete"
791+
// flag (0x00) so the receiver short-circuits the chunk
792+
// reassembly path.
793+
final packet = Uint8List(1 + data.length);
794+
packet[0] = 0x00; // complete (non-chunked)
795+
packet.setRange(1, packet.length, data);
796+
await _peripheralUpdateValue(
797+
BleConstants.positionCharUuid,
798+
packet,
799+
);
800+
} else {
801+
// Send the payload in chunks via the same protocol the
802+
// central-mode `_sendChunked` uses (flag bytes 0x01/0x02/0x03
803+
// + sequence number) so the receiver's reassembly logic
804+
// works without modification.
805+
await _sendChunkedViaPeripheral(
806+
BleConstants.positionCharUuid,
807+
data,
808+
maxChunkPayload,
809+
);
769810
}
770811
} catch (e) {
812+
if (kDebugMode) {
813+
print('[BleTransport] peripheral broadcast failed: $e');
814+
}
771815
errors.add('peripheral-broadcast: $e');
772816
}
773817
}
@@ -825,6 +869,96 @@ class BleTransport implements TransportService {
825869
}
826870
}
827871

872+
/// Push one notification to subscribed centrals via the peripheral
873+
/// platform channel. Wraps the `updateValue` invocation with retry on
874+
/// transmit-queue-full (iOS returns false when the queue is full and
875+
/// expects the app to wait for `peripheralManagerIsReady` before
876+
/// retrying).
877+
Future<void> _peripheralUpdateValue(String charUuid, Uint8List packet) async {
878+
// Best-effort retry loop. On iOS `pm.updateValue` returns false when
879+
// the transmit queue is full; we block briefly and retry up to 3
880+
// times before giving up. The native side surfaces the boolean
881+
// result via the platform-channel return value. (v1.5.4+311 fix —
882+
// before this loop, a single congested notification was silently
883+
// lost.)
884+
const maxRetries = 3;
885+
for (var attempt = 0; attempt < maxRetries; attempt++) {
886+
try {
887+
final result = await _advertiserChannel.invokeMethod<bool>(
888+
'updateValue',
889+
{
890+
'characteristicUuid': charUuid,
891+
'data': packet,
892+
},
893+
);
894+
if (result == true || result == null) {
895+
return;
896+
}
897+
} on PlatformException catch (e) {
898+
if (kDebugMode) {
899+
print('[BleTransport] _peripheralUpdateValue platform error '
900+
'attempt=$attempt: ${e.message}');
901+
}
902+
}
903+
await Future<void>.delayed(const Duration(milliseconds: 25));
904+
}
905+
if (kDebugMode) {
906+
print('[BleTransport] _peripheralUpdateValue: dropped after $maxRetries '
907+
'retries (transmit queue stayed full)');
908+
}
909+
}
910+
911+
/// Send [data] to all subscribed centrals using the chunking protocol
912+
/// (mirror of [_sendChunked] for the peripheral path). Uses
913+
/// [maxChunkPayload] derived from the SMALLEST subscribed central's
914+
/// `maximumUpdateValueLength` so a single notification stream is
915+
/// compatible with every subscriber.
916+
Future<void> _sendChunkedViaPeripheral(
917+
String charUuid,
918+
Uint8List data,
919+
int maxChunkPayload,
920+
) async {
921+
int offset = 0;
922+
int seq = 0;
923+
924+
while (offset < data.length) {
925+
final remaining = data.length - offset;
926+
final chunkSize = remaining > maxChunkPayload
927+
? maxChunkPayload
928+
: remaining;
929+
930+
final isFirst = offset == 0;
931+
final isLast = offset + chunkSize >= data.length;
932+
933+
int flag;
934+
if (isFirst && isLast) {
935+
flag = 0x00; // complete
936+
} else if (isFirst) {
937+
flag = 0x01; // first
938+
} else if (isLast) {
939+
flag = 0x03; // last
940+
} else {
941+
flag = 0x02; // middle
942+
}
943+
944+
final packet = Uint8List(2 + chunkSize);
945+
packet[0] = flag;
946+
packet[1] = seq & 0xFF;
947+
packet.setRange(2, 2 + chunkSize, data, offset);
948+
949+
await _peripheralUpdateValue(charUuid, packet);
950+
951+
offset += chunkSize;
952+
seq++;
953+
954+
// Same inter-chunk pacing as central-mode `_sendChunked` to keep
955+
// the transmit queue from saturating.
956+
if (!isLast) {
957+
await Future<void>.delayed(const Duration(milliseconds: 5));
958+
}
959+
}
960+
}
961+
828962
/// Cache the writable characteristic for a device after initial
829963
/// service discovery (avoids re-discovering on every send).
830964
void _cacheWritableCharacteristic(
@@ -1003,15 +1137,27 @@ class BleTransport implements TransportService {
10031137

10041138
case 'onCentralSubscribed':
10051139
final centralId = data['centralId'] as String? ?? '';
1006-
if (kDebugMode) print('[BleTransport] Central connected (peripheral): $centralId');
1140+
// CBCentral.maximumUpdateValueLength surfaced from native.
1141+
// Defaults to BleConstants.minMtu - 3 (= 20) when the platform
1142+
// didn't include it in the event (older builds, or Android
1143+
// where the API is different). We use this to chunk our
1144+
// notify payloads so iOS doesn't silently truncate.
1145+
final maxUpdateLen = (data['maxUpdateLength'] as num?)?.toInt() ??
1146+
(BleConstants.minMtu - _attOverhead);
1147+
if (kDebugMode) {
1148+
print('[BleTransport] Central connected (peripheral): '
1149+
'$centralId maxUpdateLen=$maxUpdateLen');
1150+
}
10071151
_peripheralCentrals.add(centralId);
1152+
_peripheralCentralMaxUpdateLength[centralId] = maxUpdateLen;
10081153
_setState(TransportState.connected);
10091154
break;
10101155

10111156
case 'onCentralUnsubscribed':
10121157
final centralId = data['centralId'] as String? ?? '';
10131158
if (kDebugMode) print('[BleTransport] Central disconnected (peripheral): $centralId');
10141159
_peripheralCentrals.remove(centralId);
1160+
_peripheralCentralMaxUpdateLength.remove(centralId);
10151161
if (_connectedDevices.isEmpty && _peripheralCentrals.isEmpty) {
10161162
_setState(TransportState.disconnected);
10171163
}

lib/services/field_link/transport/ios_p2p_transport.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22

3+
import 'package:flutter/foundation.dart';
34
import 'package:flutter/services.dart';
45
import 'package:red_grid_link/core/errors/app_exceptions.dart';
56
import 'package:red_grid_link/data/models/peer.dart';
@@ -327,6 +328,22 @@ class IosP2pTransport implements TransportService {
327328
_handleSessionStateChanged(data);
328329
case 'onDataReceived':
329330
_handleDataReceived(data);
331+
case 'onError':
332+
// v1.5.4+311: surface MPC failures (advertiser/browser couldn't
333+
// start, invitation timed out, etc.) to the transport state
334+
// stream instead of silently swallowing them. Without this, a
335+
// broken Bonjour registration (e.g. user denied iOS Local
336+
// Network permission, or the NSBonjourServices Info.plist entry
337+
// is incomplete) would cause MPC to silently never deliver
338+
// anything — masking the BLE notify bug it was supposed to
339+
// compensate for. Per the v1.5.4 audit, this was a contributing
340+
// factor to the iPad↔iPhone Field Link regression.
341+
if (kDebugMode) {
342+
final msg = data['message'] as String? ?? '<no message>';
343+
// ignore: avoid_print
344+
print('[IosP2pTransport] native error: $msg');
345+
}
346+
_setState(TransportState.error);
330347
}
331348
}
332349

@@ -335,9 +352,26 @@ class IosP2pTransport implements TransportService {
335352
final peerName = data['peerName'] as String? ?? 'Unknown';
336353
if (peerId == null) return;
337354

355+
// v1.5.4+311: extract the host's session id from the MPC
356+
// discoveryInfo dict. The native side (`MultipeerChannel.swift`
357+
// line ~131) packs `sessionId` into discoveryInfo when the host
358+
// calls `startDiscovery`. Without parsing it here, every
359+
// MPC-discovered peer surfaced with `sessionId: null`, the
360+
// session_join_card's null-guard blocked the join, and the bug
361+
// user repeatedly hit could not be worked around even by MPC.
362+
String? sessionId;
363+
final info = data['discoveryInfo'];
364+
if (info is Map) {
365+
final raw = info['sessionId'];
366+
if (raw is String && raw.isNotEmpty) {
367+
sessionId = raw;
368+
}
369+
}
370+
338371
_discoveryController.add(DiscoveredDevice(
339372
id: peerId,
340373
name: peerName,
374+
sessionId: sessionId,
341375
deviceType: DeviceType.ios,
342376
discoveredAt: DateTime.now(),
343377
));

lib/ui/screens/field_link/widgets/session_join_card.dart

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -196,32 +196,23 @@ class _SessionJoinCardState extends ConsumerState<SessionJoinCard> {
196196
Future<void> _joinSession(DiscoveredDevice device) async {
197197
final colors = ref.read(currentThemeProvider);
198198

199-
// The host's session UUID v4 is broadcast in the BLE service-data
200-
// advertising field and parsed into [DiscoveredDevice.sessionId] by
201-
// [BleTransport]. If it's missing, the host is on an old build that
202-
// didn't include sessionId in its advertisement — the user must use
203-
// the QR or Manual entry path to enter the full session id.
204-
//
205-
// Calling joinSession(device.id) instead would pass the BLE peripheral
206-
// CoreBluetooth UUID as the sessionId, which mismatches the host's
207-
// UUID v4 and silently breaks all CRDT sync (the cause of multiple
208-
// post-v1.5.2 reviewer reports).
209-
final sessionId = device.sessionId;
210-
if (sessionId == null || sessionId.isEmpty) {
211-
notifyError();
212-
if (mounted) {
213-
ScaffoldMessenger.of(context).showSnackBar(
214-
const SnackBar(
215-
content: Text(
216-
'This host did not broadcast its session id. Update both devices, or use Scan QR / Enter Manually.',
217-
style: TextStyle(color: Colors.white),
218-
),
219-
backgroundColor: Color(0xFFCC0000),
220-
),
221-
);
222-
}
223-
return;
224-
}
199+
// Use the host's broadcasted sessionId if available, otherwise fall
200+
// back to the BLE remote id. iOS hosts cannot include serviceData in
201+
// CBPeripheralManager advertisements (Apple silently strips the key;
202+
// an earlier attempt to set CBAdvertisementDataServiceDataKey caused
203+
// a SIGABRT — see v1.5.4+307 → +308 hotfix). So when the host is
204+
// iOS, the joiner sees `device.sessionId == null`. The v1.5.4+306
205+
// audit added a hard null-guard here that blocked the join entirely
206+
// with a "host did not broadcast its session id" snackbar — that's
207+
// what produced the iPhone-cannot-join-iPad regression TestFlight
208+
// testers hit. Falling back to `device.id` matches v1.5.2 behaviour:
209+
// SyncEngine does not gate incoming payloads by sessionId, so a
210+
// sessionId mismatch is at most a cosmetic mislabel on the joiner's
211+
// local Drift session row, not a sync break. Fixed in v1.5.4+311.
212+
final sessionId =
213+
(device.sessionId == null || device.sessionId!.isEmpty)
214+
? device.id
215+
: device.sessionId!;
225216

226217
// We cannot know the security mode from BLE advertisement data alone.
227218
// Always prompt for PIN — the user can leave it empty for open sessions.

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: red_grid_link
22
description: Offline-first MGRS-native proximity coordination platform
33
publish_to: 'none'
4-
version: 1.5.4+310
4+
version: 1.5.4+311
55

66
environment:
77
sdk: '>=3.2.0 <4.0.0'

0 commit comments

Comments
 (0)