Skip to content

feat(bindings): Android forms its Wi-Fi Direct group itself, without a system dialog - #507

Open
kivtxs wants to merge 8 commits into
mainfrom
feat/android-wifi-direct-groups
Open

kivtxs wants to merge 8 commits into
mainfrom
feat/android-wifi-direct-groups

Conversation

@kivtxs

@kivtxs kivtxs commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Rebased onto main after #505 merged (squashed as d20654d). This PR now holds only its own three commits. Two conflicts came from your review edits to #505: the integration guide (kept your fuller Wi-Fi Direct bullet and added the autoAccept one) and the CHANGELOG (kept your once-per-edge transport_switched wording). The uniffi suite (200 tests) and the Android JVM suite pass on the rebased branch.

Closes the residual #465 left open: "The Android manager does not form a group itself; it joins one the system formed." Until now, two phones running an app could only use Wi-Fi Direct after a person paired them by hand in system settings. That also left the documented autoAccept / groupOwnerIntent options doing nothing.

What it does (opt-in: wifiDirect: { enabled: true, autoAccept: true }, Android 10+)

  1. Advertise. Each device publishes a Wi-Fi P2P DNS-SD record: the stream chapter's _offlineprotocol._tcp record (txtvers=1, addr=), plus app (a tag of the app id). The spec gets a paragraph on this use.
  2. One group per app, named by the app. The group's name (DIRECT-op-<hash(appId)>) and passphrase derive from the app id alone. A joiner needs nothing from the owner. Other apps' devices never compute the passphrase, and it is never sent.
  3. Decide (WifiDirectGroupFormation.decide, a pure object every device runs):
    • a device in a group does nothing;
    • the lowest address heard creates;
    • every other device joins, and takes over creating after three joins find no group, because the lower peer may never hear it;
    • a device that heard no record joins only as a paced probe, once a minute, when a group owner is nearby (Wi-Fi Direct televisions are owners too);
    • an owner whose group stays empty for a randomised 30 to 60 s dissolves it, and then joins twice before it may create again, so two groups formed at once merge.
  4. Credentials, not invitations. The group is created and joined with WifiP2pConfig.Builder().setNetworkName().setPassphrase(), which shows no dialog on either phone. connect() by device address would show the other phone an invitation that a phone in a pocket never answers. The passphrase only keeps other apps out; the identity preamble and MLS still protect the traffic.

Radio behaviours that shaped it (found on the phones, written into the code and docs/bridges/kotlin.md)

  • A DNS-SD query by service type returns only the PTR record. Its answer reaches the service listener and never the TXT listener, where the address lives. The query now names the instance (every device publishes offlineprotocol).
  • A device that owns or is joining a group answers no service discovery query. So hearing is often one-sided: one phone hears the other minutes before the reverse, or never. The first design named the group after its owner, so a joiner had to hear the owner first. Of nine clean starts, two never formed. Naming the group by the app removes that dependency, and the takeover handles the one-sided case.
  • A join must scan for the group, and the supplicant retries a rejected association. An 8 s join window was too short. Each join attempt now gets 15 s, and attempts are 30 s apart, so a joiner stays discoverable half the time.
  • Wi-Fi Direct televisions are group owners. Joining on every "owner nearby" sighting kept both phones busy joining and deaf to each other's records, which is why that join is now a paced probe.

Validation

On the phones (Infinix NOTE 12 on Android 13, Seeker on Android 15, two Wi-Fi Direct televisions in range; Bluetooth off; no prior group; location not granted):

  • Launch to proved stream, 8 of 8 clean starts: 225, 71, 18, 81, 19, 56, 72 and 50 s (median about a minute). Wi-Fi was cycled between runs to remove the group. The first design formed 7 of 9.
  • No system dialog on either phone.
  • Over the formed group:
    • a 30-message burst delivered 30/30, send-to-receipt p50 73 to 82 ms, max 154 ms;
    • a 40-round soak: 80/80 received, and 79/80 receipts (one receipt missing, under investigation, the message itself arrived);
    • group chat 3/3 each way;
    • app restart re-proved the stream in 3 s;
    • a 30 s Wi-Fi outage: the 3 messages queued during it were all delivered after it.
  • With Bluetooth also on: DORS routes over Wi-Fi Direct (p50 59 to 88 ms), and dropping one phone's Wi-Fi falls back to Bluetooth at once (6/6 delivered).

Locally:

  • android-ci-harness: all tests pass, including WifiDirectGroupFormationTest (18). The tests cover:
    • who creates and who joins (two and three devices);
    • the one-sided cases: takeover after three failed joins, and a paced probe toward an owner heard by nobody;
    • join-first after dissolving;
    • other apps' records and this device's own record being ignored;
    • the record's entry order and parsing (including a sid record);
    • credential sizes, and different apps getting different passphrases.
  • bindings/kotlin: library tests and staging publish, the minified consumer app, check_android_aar.py and check_android_test_results.py, all green. Lint's NewApi check passes: the API 29 calls sit behind in-function SDK checks rather than a suppression.
  • Uniffi react_native* guards pass. tsc: clean. JS harness: all files pass.

Wi-Fi Direct comes back when Wi-Fi does (76929a09)

Found in the mixed-transport test.

  • The bug: turning Wi-Fi off reported the slot down. Turning it back on reported nothing, restarted no discovery, and (with formation on) left the framework's dropped service request unregistered, so every discoverServices failed with NO_SERVICE_REQUESTS. An app started with Wi-Fi off never got Wi-Fi Direct at all.
  • The fix: the manager now reports the layer up again when P2P returns (a repeated broadcast is a no-op), restarts discovery, and re-registers the request and record. It also re-registers on that error code, and pauses formation while P2P is off.
  • On the phones:
    • app started with Wi-Fi off, then Wi-Fi turned on: group formed and streams proved in 73 s;
    • one phone's Wi-Fi turned off and on mid-session: group re-formed in 19 s.

This part applies without autoAccept too (the slot is reported up again). It could live in #505 if you prefer; say so and I'll move it.

Risk

  • Off by default. Nothing changes for an app that doesn't set autoAccept.
  • When on:
    • discovery runs while ungrouped (every 15 s) and stops once settled;
    • a device may briefly own an empty group;
    • all radio calls run on the manager's existing confinement looper.

Not covered / known limits

  • Three or more devices are untested; the rules are unit-tested for three.
  • Formation time varies widely (18 to 225 s) because hearing is one-sided and paced. Bluetooth, when on, carries traffic meanwhile.
  • Android 9 and lower keep using a group formed in settings.
  • iOS is unaffected (Network framework, no groups).

@kivtxs
kivtxs requested a review from bahdotsh October 5, 2026 22:06
@kivtxs
kivtxs changed the base branch from fix/android-wifi-direct to main October 5, 2026 22:47
@kivtxs kivtxs closed this Oct 5, 2026
@kivtxs kivtxs reopened this Oct 5, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 5, 2026
@kivtxs
kivtxs force-pushed the feat/android-wifi-direct-groups branch 4 times, most recently from 6dc721d to 01afaf0 Compare October 6, 2026 04:37
kivtxs added 3 commits October 6, 2026 10:48
…a system dialog

Closes the residual #465 left open ("the Android manager does not form a
group itself"). With wifiDirect.autoAccept on Android 10+:

- Devices of one app find each other over Wi-Fi P2P DNS-SD: the stream
  chapter's _offlineprotocol._tcp record (txtvers, addr) plus `app` (a tag of
  the app id) and `net` (the group name, while owning one).
- The lowest address creates a group under a name derived from its address;
  the others join it with a passphrase derived from the app id. Joining by
  credentials shows no dialog on either phone, unlike connect() by device
  address, whose invitation a phone in a pocket never answers.
- The query names the instance, because a query by type returns only the PTR
  record and never reaches the TXT listener. A group owner answers no service
  discovery query, so joiners target the name the lowest peer will create,
  and an owner whose group stays empty for 45 s dissolves it so it can be
  found again.

The rules are a pure object (WifiDirectGroupFormation) with 20 JVM tests. Off
by default; groupOwnerIntent stays unused. Measured on an Android 13 and an
Android 15 phone: launch to proved stream in 26-55 s over five clean starts,
chat 3/3 and 4/4 each way at ~100 ms.
Turning Wi-Fi P2P off reported the slot down; turning it on reported
nothing, restarted no discovery and, with group formation on, left the
framework's dropped service request and record unregistered, so every
later discoverServices failed with NO_SERVICE_REQUESTS. An app started
with Wi-Fi off never got Wi-Fi Direct.

The manager tracks whether it last reported the layer up, reports it up
again when P2P returns (a repeated broadcast is a no-op), restarts peer
discovery, re-registers formation's service request and record, and
re-registers the request on NO_SERVICE_REQUESTS. Formation pauses while
the layer is down instead of failing BUSY.

On the phones: app started with Wi-Fi off, then Wi-Fi on: group formed
and streams proved in 73 s; Wi-Fi off and on mid-session on one phone:
re-formed in 19 s.
…tials

The first formation design named the group after its owner, so a joiner had
to hear the owner's DNS-SD record before it could join. On two phones that
failed: a device that owns or is joining a group answers no service
discovery query, and the two often heard each other minutes apart or never.
Of nine clean starts, two never formed and two took about two minutes.

- The group's name and passphrase derive from the app id alone, so a joiner
  needs nothing from the owner. The `net` record entry is gone.
- The lowest address heard creates; others join, and take over creating
  after three joins found no group (the lower peer may never hear them).
- A device that heard no record joins only as a paced probe (once a minute)
  when a group owner is nearby: Wi-Fi Direct televisions are owners too, and
  joining on every step kept both phones deaf.
- A join attempt is cancelled after 15 s (it must scan, and the supplicant
  retries a rejected association) and attempts are 30 s apart, so a joiner
  stays discoverable half the time.
- An owner whose group stays empty for a randomised 30-60 s dissolves it and
  joins twice before it may create again, so groups formed at once merge.

Eight clean starts on an Android 13 and an Android 15 phone next to two
Wi-Fi Direct televisions: all formed, 18-225 s, median about a minute, no
system dialog. 18 JVM tests cover the rules, including the asymmetric cases.
@bahdotsh
bahdotsh force-pushed the feat/android-wifi-direct-groups branch from 01afaf0 to c3e71d5 Compare October 6, 2026 05:18
… it on stop

Calling enableTransport('wifiDirect') with no config, which the
integration guide tells apps to do once a permission is granted,
turned group formation *off*. A config that didn't name autoAccept
was read as false.

Then it got worse. stop() decided whether to clean up by checking
the flag that had just been cleared, so it skipped removing the
group this run had created. The device kept owning an empty
app-named group, and an owner answers no service discovery, so its
peers could no longer find it. Confusion ensues.

So a config that doesn't name the key now keeps the current
setting, and it is applied after the stop. stop() undoes what the
run actually registered and created, no matter what the flag says
by then.

While at it:
 - The join timeout is a named runnable that stop() removes. A
   timer left over from the previous run could otherwise cancel the
   next run's join and count a failure it never saw.
 - A join the framework refuses outright (BUSY, say) now counts
   toward the takeover. Before, a device whose joins kept failing
   never got to create the group itself.
 - A device alone stops working the radio as hard. Service
   discovery backs off from 15 to 60 s while nothing is heard, and
   the owner probe from one to four minutes. A Wi-Fi Direct printer
   or TV is a group owner no probe will ever join, and probing one
   every minute forever is not great. Both reset when a record, a
   new owner or a new device shows up.
 - The passphrase comment no longer claims a forger can't get in.
   Anyone who knows the app id can compute the passphrase.
… risk

The bridge doc and the CHANGELOG still described the first design:
an eight-second join window (the code uses fifteen) and an
unthrottled join toward any group owner (the code probes once a
minute). These are the docs you're told to read before changing
the rules, so they had better be right.

The integration guide's example set autoAccept: true back when the
option did nothing. Apps that copied it start forming groups on
upgrade, and the CHANGELOG now says so instead of claiming nothing
changes. groupOwnerIntent is marked deprecated everywhere it is
still documented as working.

The group passphrase is derived from the app id, which ships inside
every copy of the app, so it is not a secret. Add R22 to the threat
model: who can join or squat on the group, what forged records can
steer, and why the preamble and MLS are the real protection. The
spec paragraph points to it.
A Wi-Fi P2P group belongs to the system, not to the process that
created it. So when the owner's app gets killed in the background,
the group stays up with nobody listening on the stream port, and an
owner answers no service discovery query, so nobody can tell.

Every other device of the app then sees "a group owner nearby",
probe-joins it by the derived credentials, and succeeds. Now it is
in a group, so formation never runs again, and it redials a dead
port once a minute for the rest of the session. Wi-Fi Direct is
gone until somebody cycles Wi-Fi. The threat model claimed idle
owners bound a squatter. A dead owner is never idle. It just isn't
there.

Two rules close it. A client that is in an app-named group leaves
after three dials in a row at the top of its redial ladder prove
nothing, and joins before creating again. Dials below the ceiling
don't count, so an owner that is just restarting its app doesn't
get abandoned. A group paired by hand in system settings is never
left. And an app-named group this device owns is removed by stop()
even if it was adopted at start rather than created in this run.
No other app derives that name, and leaving it up is how a device
ends up as a hidden, serverless owner in the first place.

The leave decision lives in GroupOwnerRedial, a pure function, so
it is pinned without a radio.

While at it: a new peer or a new record now makes the next service
discovery due immediately. Previously it only shortened the period
*after* a wait that could already be 60 s out. Also pace the local
record publish retry, which was the one formation call that would
retry every 10 s against a framework that keeps refusing it.
The guide and the autoAccept doc comment said the group forms
"within about a minute". The measurements say 18 to 225 s, with
half the runs over 56 s. The median is about a minute. That is not
the same claim.

Say one to two minutes and give the measured range, as the
CHANGELOG already does.
The previous commit taught a client to leave an app-named group
whose owner proves nothing. It left, all right. Ten seconds later it
joined the exact same dead group again.

The reason is that every group of an app has the *same* name. A
join by name and passphrase lands on whichever owner of that name
the supplicant finds, and the only one in sight is the owner we just
walked away from. The leave also set join-first, and the dead owner
still counted as "an owner nearby", so both the join-first rule and
the owner probe aimed straight back at it. Captured for three
minutes, leave, captured again. For the whole session. The CHANGELOG
and R22 said "forms again". It never did.

So remember the owner we left, by device address, for five minutes:
longer than an owner whose app comes back takes to dissolve its
empty group. It no longer counts as an owner nearby, and while it
is remembered every join names its target with setDeviceAddress.
The config still carries the network name and passphrase, so it
stays a join by credentials and shows no invitation. With no other
owner in sight the join is counted as one that found nothing, so
the takeover makes the device create its own group. The leave no
longer sets join-first.

Second hole in the same place: asking to leave also reset the
count and *returned* before posting the redial. If removeGroup
failed (BUSY during a group transition, say) the client sat in the
dead group with no stream, no redial, no leave retry and no
formation. Nothing would ever fire again. Keep redialling; a failed
leave is asked again on the next unproved dial, and a Rust guard
now pins that shape.

While at it:
 - Recount an owner's clients on PEERS_CHANGED too. A client
   leaving may not change the owner's connection, and an owner
   that still thinks it has a client never dissolves.
 - Don't publish the record twice at start. The first step ran
   before the first publish answered and started a second
   clear-and-add chain alongside it.
 - An owner counts as new, and resets the probe backoff, only
   after five minutes out of the peer list. A television at the
   edge of range flickering in and out re-armed the one-minute
   probe every time.

Not device-tested: that a targeted join by credentials shows no
dialog, and that the peer list's device address is the one
setDeviceAddress wants. If either is wrong the join fails and
counts toward the takeover, which is the safe way to be wrong.

This branch has not been deployed

No deployments
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