Skip to content

fix(bindings): Android Wi-Fi Direct sends at once, rejoins its group, and starts from the config - #505

Merged
bahdotsh merged 6 commits into
mainfrom
fix/android-wifi-direct
Oct 6, 2026
Merged

bahdotsh merged 6 commits into
mainfrom
fix/android-wifi-direct

Conversation

@kivtxs

@kivtxs kivtxs commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Four faults from the v0.28 device smoke test of Wi-Fi Direct on two Android phones: an Infinix NOTE 12 (Android 13) and a Solana Seeker (Android 15), with Bluetooth off and no internet. Main at c8a5d12 passes CI, but on the phones Wi-Fi Direct was off unless the app turned it on by hand, unavailable on Android 13+ unless the app declared permissions the guide doesn't list, about 3 s per delivery when it did run, and unreachable after any app restart.

What was wrong, and the fix

1. Every Wi-Fi Direct frame waited for the 2 s fallback poll. wireTransportCallbacks() runs at create and registered the Wi-Fi Direct callback inside wifiDirectManager?.let. The manager is only ever built later, by enableTransport, so the callback was never registered in any app. Every body and every acknowledgement rode WifiDirectManager's 2 s poll. Measured on the phones: median about 3 s per delivery (0.3 to 8 s), usually with a retry. A trace of one message showed it arriving in about 100 ms; the acknowledgement then sat in the queue until the next poll.
Fix: register the callback whenever the config enables the slot, and resolve the manager when it fires, so a manager that enableTransport replaces needs no second registration. The field becomes @Volatile, since the core's thread reads it. iOS already registers when it builds its manager (OfflineProtocolModule.swift, the wifi_direct arm of enableTransport); Android now matches.

2. After an app restart, the phone never rejoins its Wi-Fi Direct group. The group belongs to the system and outlives the process. Since Android 10, WIFI_P2P_CONNECTION_CHANGED_ACTION is no longer a sticky broadcast, so the receiver registered in startUnsafe() hears nothing about a group that already exists. The owner never listened and the client never dialled; the only recovery was removing the group in system settings. This reproduced after every restart, on both phones.
Fix: adoptExistingGroup() runs once when the manager is running: requestConnectionInfo, and if groupFormed, hand it to handleConnectionChanged(true), the same path the broadcast takes.

3. transports.wifiDirect.enabled: true did nothing. start() auto-enables internet, Nostr and Reticulum from their config sections but not this one, and nothing is logged. The demo app sat on "Searching for peers" next to a live group.
Fix: start() enables wifiDirect after the native start, logging rather than throwing on failure, like the others. iOS behaviour change: an iOS app with wifiDirect.enabled: true now starts the Network framework peer stream at start(), and gets the Local Network prompt on first run. This is noted in the changelog.

4. Missing Wi-Fi Direct permissions. The module declared the Bluetooth permissions but not ACCESS_WIFI_STATE, CHANGE_WIFI_STATE or NEARBY_WIFI_DEVICES. An app following the guide got "WiFi P2P is not available on this device" on Android 13+.
Fix: the manifest declares them, with NEARBY_WIFI_DEVICES set to neverForLocation (the manager never derives location), plus INTERNET for the stream socket. On 13+ hasRequiredPermissions() no longer demands ACCESS_FINE_LOCATION. That is what neverForLocation allows, and it is verified below with location revoked. Android 12 and lower still need fine location for peer discovery, which stays with the app (as BLE's location does on 11 and lower), and the guide now says so.

Validation

On the phones (demo app built from this branch, location revoked on both, no app-side workaround):

  • Config auto-enable: both logged Wi-Fi Direct transport auto-enabled.
  • Group adoption: both logged Joining a group formed before start into a group left over from the previous run. The client, started a minute before the owner, redialled and proved its stream 16 s after the owner came up.
  • Pairing and chat: connection request delivered and accepted, then 4 of 4 messages each way, all with receipts. Send-to-receipt median 105 to 120 ms, worst 178 ms. Before the fix, the same test had a median of about 3 s.

Locally:

  • cargo test -p offline-protocol-uniffi --lib: 191 passed. The two new guards fail on main's Kotlin and pass here.
  • cargo fmt --all -- --check: clean.
  • npm run test:js: all 12 harness files pass, including the new wifi-direct-config.test.js, which fails 3 of 4 against main's index.ts.
  • npx tsc --noEmit: clean.
  • Android unit tests through android-ci-harness with Gradle 8.9 and JDK 17: 571 tests, 0 failures.
  • scripts/tests/test_check_android_aar.py: 43 passed.

Guards added

  • react_native_android_wifi_direct_callback_does_not_wait_for_a_manager: pins the slot-gated registration and the volatile field, and refuses the wifiDirectManager?.let form.
  • react_native_android_wifi_direct_joins_a_group_formed_before_start: pins the call in startUnsafe() and that the adoption goes through handleConnectionChanged(true).
  • js-ci-harness/wifi-direct-config.test.js: enables with the whole section, after the native start; an absent or disabled section starts nothing; a failure warns without failing start().
  • NEARBY_WIFI_DEVICES is added to REQUIRED_IN_MANIFEST in check_android_aar.py.

Risk

  • Android: additive. A new callback registration (the same object shape iOS uses), one requestConnectionInfo at start, and manifest permissions that merge into the app. The merger combines non-conflicting attributes, so neverForLocation reaches every consuming app's merged manifest, including one that declared NEARBY_WIFI_DEVICES itself without the flag. An app that derives location from Wi-Fi must replace the declaration with tools:node="replace" and then also hold ACCESS_FINE_LOCATION on 13+ (see the README).
  • iOS: the auto-enable described in fix 3.

Added after review: neighbour and transport events (46203fcc, be01882a)

The extended device matrix turned up three event bugs that every app sees:

  • One Bluetooth departure arrived as two neighbor_lost. Android gives up on each stale address of a peer separately. ble_peer_lost now reports only a peer it knew.
  • A peer still reachable on the other carrier was reported lost. Leaving the Wi-Fi Direct group while linked over Bluetooth, or the reverse, emitted neighbor_lost and cleared core discovery tracking. Both loss paths now skip while another carrier links the peer.
  • transport_switched to WiFiDirect fired when the stream layer started, peer or no peer. It now follows the first proved link and the last one lost.
  • Then the reverse, found on the phones after the fix above: Bluetooth switched off while both carriers linked the peer. Android reports the adapter unavailable but delivers no per-link disconnect, so the peer still counted as linked over Bluetooth, and the later Wi-Fi Direct loss was skipped. The Seeker showed a Bluetooth neighbour that did not exist. ble_status_changed(false) now ends the core's Bluetooth peers, reporting each lost once unless Wi-Fi Direct still reaches it. The Android side of the same outage, dead GATT links left in place, is fix(bindings): Bluetooth peers find each other in busy rooms, and Android survives Bluetooth toggles and stack crashes #511.

Six uniffi tests cover these. The two for the last fix fail without it.

796db2a7 corrects docs/react-native-integration.md: start() now restores Wi-Fi Direct from the config.

Not in this PR

… and starts from the config

Four faults found smoke-testing Wi-Fi Direct on two Android phones
(Infinix NOTE 12, Android 13; Solana Seeker, Android 15):

- The send callback was registered at create only if a manager existed,
  and the manager is built later by enableTransport, so every body and
  acknowledgement waited for the 2s fallback poll (median ~3 s per
  delivery, 0.3 to 8 s). It is now registered for the slot and resolves
  the manager when it fires (median ~110 ms on the same phones).
- WIFI_P2P_CONNECTION_CHANGED_ACTION is not sticky since Android 10, so a
  manager started inside an existing group never joined it. It now asks
  for the group once running.
- start() auto-enabled internet, Nostr and Reticulum but not
  transports.wifiDirect, so the documented config left it off.
- The module did not declare ACCESS_WIFI_STATE, CHANGE_WIFI_STATE or
  NEARBY_WIFI_DEVICES, so the transport reported itself unavailable on
  Android 13+. NEARBY_WIFI_DEVICES is declared neverForLocation and the
  13+ check no longer demands a location grant.

Guards: two source guards in the uniffi crate, a JS harness test, and
NEARBY_WIFI_DEVICES in the AAR checker's required manifest entries.
kivtxs added 3 commits October 5, 2026 19:20
…nd Wi-Fi Direct switches by link

From the v0.28 device test on two Android phones:

- One Bluetooth departure arrived as two neighbor_lost (Android gives up on
  each stale address separately). ble_peer_lost now reports only a peer it
  knew.
- A peer that left the Wi-Fi Direct group but stayed linked over Bluetooth
  LE, or the reverse, was reported lost and cleared from core discovery
  tracking. Both loss paths now skip the report while the other carrier
  still links to the peer.
- transport_switched to WiFiDirect fired when the stream layer came up,
  which managers do at start with no peer; it now follows the first proved
  link and the last one lost.

Four tests in the uniffi crate.
…irect loss is reported

Found on the two phones after the previous commit: Bluetooth was switched off
while both carriers linked the peer, then Wi-Fi Direct dropped. Android
reports the adapter unavailable but delivers no per-link disconnect, so the
peer still counted as linked over Bluetooth; the Wi-Fi Direct loss was
skipped as "linked elsewhere" and the app listed a neighbour nothing reached.

ble_status_changed(false) now drops the Bluetooth peers and reports each one
lost once, unless Wi-Fi Direct still reaches it. A platform that reports its
peers lost first (iOS does on radio loss) leaves none, and a late per-peer
report adds nothing.

Two tests in the uniffi crate; both fail without the change.
The previous commit moved transport_switched off the layer and onto
the links, and decided the edge by reading the link count before and
after each per-peer report. That count is gated on the layer being
up. So it lies exactly when the layer goes down.

iOS takes the layer down when the app backgrounds, and the OS kills
the streams afterwards. With two links held, the flip emitted
nothing, and then each late stream end saw zero links and emitted
its own switch to None. Two switches away for one departure. Links
proved while the layer was down did the mirror image: one switch to
WiFiDirect per link. And the flip clears the links silently, so the
core kept both neighbours until the late ends trickled in. The
comment said a layer going down reports its links lost first.
Android and Python do. iOS doesn't, and nothing enforced it.

Let's stop relying on caller ordering. The edge is now decided in
one place, from the visible count read under the same lock as the
change, and the status call runs it too: a flip that hides N links
switches once, a flip that reveals them switches once. The flip
also reports every link it ended lost, unless Bluetooth still
reaches the peer, the same shape ble_status_changed got. A peer
disconnect now reports only a link the transport actually held, so
the late ends, and the end of a stream that never proved anything,
add nothing.

While at it, the iOS BleManager comment that said the status call
clears only the transport's peer map is no longer true; fix it.
The library now declares NEARBY_WIFI_DEVICES with neverForLocation,
and on Android 13+ the Wi-Fi Direct manager checks that permission
alone. That is only correct while the flag is there. The release
check pinned the permission's name and nothing else, so an AAR that
lost the flag passed, and every app on it would quietly need a
location grant the manager never asks about. Discovery then fails
with a bare ERROR instead of the transport saying it's unavailable.
The check now refuses a declaration without the flag.

The docs had it backwards too. The README still told apps to declare
the Wi-Fi permissions themselves, without the flag, and the PR claimed
an app's own flag-less declaration wins the merge. It doesn't. The
merger combines non-conflicting attributes, so the library's flag
lands in every app that adds it. That is what we want, but an app
that really does derive location from Wi-Fi has to know to replace
the declaration, and that it then needs fine location on 13+ that
the SDK won't check for it. Say so.

While at it, document that start() enables Wi-Fi Direct once, so the
runtime grant has to come first. A refused enable is logged and
never retried.
@bahdotsh
bahdotsh merged commit d20654d into main Oct 6, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 6, 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.

2 participants