Skip to content

fix(bindings): serve the Hello characteristic from the iOS peripheral - #636

Merged
bahdotsh merged 4 commits into
mainfrom
feat/ios-peripheral-hello
Oct 25, 2026
Merged

bahdotsh merged 4 commits into
mainfrom
feat/ios-peripheral-hello

Conversation

@bahdotsh

Copy link
Copy Markdown
Member

What this fixes

An iPhone could not name a central it cannot dial back. The iOS peripheral served Message, Device id, Identity and App tag but not Hello. Every fragment a central wrote was queued behind ensureDeviceId, which dials the central's address to read its identity.

  • Android phone with a rotated address: the dial-back costs seconds to tens of seconds.
  • Browser over Web Bluetooth: it serves no GATT, so the dial-back never completes. This PR is the prerequisite for the browser's Bluetooth carrier reaching an iPhone.

Android and the Python service already serve Hello. The iOS central already writes it.

What changes

  • Hello in the service. The characteristic is write-only and dynamic.
  • BlePeripheralHelloPolicy. It sits beside the central's BleHelloPolicy and refuses a write before anything is verified when it is long, offset, or outside 96 to 512 bytes. CoreBluetooth hands a long write over as several requests in one call, so more than one Hello request in a call counts as long.
  • handleInboundHello. It is the iOS copy of Android's handleInboundHelloOnBleThread, in this order:
    1. Verify with the one verifier.
    2. Bind the central to the derived address only, and never rebind a link bound to another peer.
    3. Flush the per-peer MTU before announcing the peer, once.
    4. Deliver the fragments the central wrote before its hello.

Testing

  • swift test --package-path bindings/react-native/ios: 497 pass, three of them new for the peripheral policy.
  • The CI iOS bridge typecheck command is clean against the iPhone SDK. Negative control: a bad member access in the new handler is reported as an error.
  • react_native_ios_peripheral_serves_hello is new. It pins the service list, the verdict routing, and the verify-then-bind and MTU-then-announce orders. It fails when Hello is dropped from the service.

Not done

  • No device run. An Android central writing Hello to an iPhone, and a browser over Web Bluetooth writing to an iPhone, are both untested. The second waits on the Bluetooth carrier PR.

A central that cannot be dialled back was never named by an iPhone. The
iOS peripheral served Message, Device id, Identity and App tag, so a
central writing to it had every fragment queued behind ensureDeviceId,
which dials the central's address to read its identity. That costs seconds
to tens of seconds for an Android phone whose advertising address rotated,
and never completes for a central with no GATT server at all, such as a
browser over Web Bluetooth.

The peripheral now serves Hello, as Android and the Python service do. A
write is answered through BlePeripheralHelloPolicy before anything is
verified: a long write, a non-zero offset or a length outside 96 to 512
bytes is refused. An accepted hello is verified with the one verifier, binds
the central to the derived address only, never rebinds a link bound to
another peer, flushes the per-peer MTU before announcing the peer once, and
then delivers the fragments the central wrote before it said hello.

react_native_ios_peripheral_serves_hello pins the service list, the
verdict routing and the verify-then-bind and MTU-then-announce orders; it
fails when Hello is dropped from the service.
…ied path

react_native_ble_announces_only_cross_checked_addresses required exactly one
blePeerDiscovered call in BleManager.swift. The peripheral's verified hello
is a second verified path in the same file (Android keeps its own in
BleTransportFacade.kt, outside the file the guard counts). The guard now
requires exactly two in Swift, one in completePeerHandshake after the
cross-check and one in handleInboundHello after verification, and fails on a
third call site anywhere.
Three guards each carried their own copy of "find this Swift
signature, cut at the next function". Two of them knew that a body
ends at a declaration of any visibility. The hello guard did not: it
cut at the next `private func` only, and the declaration after the
hello handler is a `public` delegate method. So its "body" ran on
through the subscribe and unsubscribe handlers, and an assertion
about the hello path would have passed on code in either.

This is not great. A guard that reads more than the function it
names passes on things it was written to refuse.

Lift one slicer beside the Kotlin one and use it everywhere. It
panics when a signature is gone, as the copies did, so a rename
still fails loudly instead of slicing an empty string.
A hello makes a kind of link the iOS bridge never had: a central
named by what it wrote, with no peripheral-role link behind it. The
previous commit built how that link is born. Nobody built the rest.

It turns out the rest was being left to code that assumed every
named central had also been dialled back:

- The mesh controller counted the link only from the pending-
  fragment drain, and that returns early when nothing was queued. A
  central that says hello before its first write, as the chapter
  orders, has nothing queued. So the link never counted against the
  connection budget and the advertisement never refreshed.

- Every "peer lost" report comes from a central-manager disconnect.
  A browser has none. When it went away, the core kept it as a
  neighbour, kept its MTU, and went on sending to a link that was
  gone. Confusion ensues.

- Subscribing still dialled the central back at once, racing the
  hello it was about to write: ten seconds of futile dial to a
  rotated Android address, and for a browser a dial that found no
  service and cancelled the connection the browser was using.

- A central turned away at subscribe time for cap or budget was
  still named and announced, with no notify target behind it.

So both verified paths now count their link through one helper,
unconditionally, and that also cancels any pending loss report. A
central leaving schedules a loss report 15 seconds out, as Android
does, which fires only if no link to the peer survives. That means
checking the departed identifier too, because it can also be our
own still-connected dial, and the link-resolution helper excludes it
on both roles. Subscribing waits two seconds for a hello before it
dials, and the dial re-checks the binding when it runs. A hello
from a central we did not keep is ignored, as Android ignores one
from an address with no server connection.

Every shipped central (iOS, Android, Python and the browser carrier)
subscribes before it writes its hello, so the subscription check
refuses nothing legitimate.
@bahdotsh
bahdotsh merged commit 44bc515 into main Oct 25, 2026
42 checks passed
@bahdotsh
bahdotsh deleted the feat/ios-peripheral-hello branch October 25, 2026 17:13
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant