Skip to content

feat(bindings): Android peer streams on a shared Wi-Fi network - #534

Merged
bahdotsh merged 24 commits into
mainfrom
feat/android-lan-peer-streams
Oct 8, 2026
Merged

bahdotsh merged 24 commits into
mainfrom
feat/android-lan-peer-streams

Conversation

@mizanxali

Copy link
Copy Markdown
Contributor

Android and iOS never met on the peer-stream slot: Android only spoke inside a Wi-Fi P2P group, which an iPhone can't join, and iOS only over Bonjour on a LAN or AWDL. Both already spoke the same stream chapter, so the gap was discovery plus one policy. With wifiDirect.enabled, Android now advertises and browses _offlineprotocol._tcp on the Wi-Fi network it is on, so it reaches iPhones and Python hosts there. ADR 0028 records the decision.

What changes

  • LAN carrier (LanPeerDiscovery.kt): NsdManager advertise and browse with the record iOS and Python publish (txtvers=1, addr, same instance name).
    • Dials on iOS's policy: the lower address dials at once, the higher after 5 s, then a redial ladder.
    • Dials are bound to the Wi-Fi network and carry the record's addr as the address the preamble must prove.
    • From Android 13, NSD is scoped to that network. Before T extensions 7 it holds a multicast lock while advertising or browsing.
    • Needs no runtime grant, so it also runs without the Wi-Fi Direct permission. Apps targeting API 37 need ACCESS_LOCAL_NETWORK.
  • Duplicate streams: Android now keeps the stream the lower address opened (UTF-8 byte order), like iOS and Python. Two different rules on a LAN reconnect forever. every_peer_stream_manager_keeps_the_same_stream pins all three copies.
  • Keepalive 15/5/3 plus a 30 s TCP_USER_TIMEOUT on Android 10 and later.
  • Inbound bounds: 16 open, at most 12 inbound and 4 per remote host. The listener already bound every interface, so any device on a shared network could fill it.
  • Carriers: streams are tagged P2P or LAN, and the slot is up while either is. P2P going off ends only the group's streams.
  • Redial ladders reset only on a stream that carried traffic (a body after the preamble, or held 30 s), on Android and iOS. Before, a proved-then-refused stream reset the ladder and redialed every second.
  • Docs: stream chapter, threat model, bridge rules (C5, K8), integration guides, CHANGELOG.md Unreleased, UPGRADING §27.

Behaviour change for apps

  • An Android app with wifiDirect: { enabled: true } now announces its address on the Wi-Fi network it joins, as an iPhone already does. There is no separate LAN switch.
  • Inside a Wi-Fi Direct group, a client whose address is higher than its owner's reconnects past a half-open stream after keepalive ends it (about 30 s, often closer to a minute) instead of at once. On Android 7 to 9 the OS keepalive defaults apply.
  • One spec change: browsers must look TXT entries up by key, since NsdManager doesn't promise attribute order. Every existing reader already does. On Android 16, txtvers was published first anyway.

Testing

  • Rust: cargo test --workspace --lib, clippy and fmt clean. New and updated source guards pin the keep rule, the LAN record and the carried-ladder rule.
  • Android JVM: ported iOS dial-policy and keep-rule tests case for case, plus inbound bounds, carriers, Ran, and the new ResolveQueue/NetworkChoice state classes.
  • Devices: Samsung (Android 16) and iPhone 16 on one Wi-Fi, plus the Python host. The Samsung had Bluetooth off and the relay was unreachable, so the LAN stream was the only path.
    • Samsung ↔ iPhone in both address orders: found each other in about 1 s, one stream, encrypted session set up, messages both ways.
    • Samsung ↔ Python host in both orders, with the host advertise-only so the Samsung had to dial: at once when lower, after 5 s when higher.
    • Burst of 300: all arrived, no duplicates.
    • Host restart on a new port: recovered in about 12 s. This was a bug found on the devices: DiscoveryListener never reports a record update.
    • Wi-Fi off and on: neighbor_lost once, then reconnected about 9 s after Wi-Fi returned, with one stream.

Not verified

  • Scoped resolves carrying the network on Android 13 to 15, and Android 12 and lower.
  • Discoverability while the app is in the background.
  • Wi-Fi Direct between two Android phones, and the P2P and LAN carriers live at once (the example app never requests the Wi-Fi Direct permission).
  • Guest networks that block multicast or isolate clients.

Follow-ups, not in this PR

  • iOS can mark a refused stream "carried" when a body arrives in the same chunk as the preamble.
  • A parked group redial has a timing window that allows one extra early dial.
  • No unit tests for the decision logic in LanPeerDiscovery.ended().

…s opened

Android kept the newer of two streams for one address, while iOS and the
Python host keep the one the lower address opened. On a shared network
both ends dial, and two different rules reconnect forever. Android now
computes the same rule (UTF-8 byte order, refused duplicates closed
without a report), tunes keepalive to 15/5/3 so a refused reconnect waits
about thirty seconds rather than two hours, and the source guard, renamed
every_peer_stream_manager_keeps_the_same_stream, pins all three copies.

ADR 0028 records the decision and the LAN carrier that follows it.
…share

The Android listener binds every interface, so on a shared network any
device could hold all sixteen stream slots. Inbound streams now take at
most twelve, leaving room to dial, and one remote address at most four,
iOS's PeerStreamDialPolicy.admitsInbound bounds, decided and taken under
one lock.
Each stream is tagged as a Wi-Fi P2P group stream or a LAN stream, and the
stream layer is reported up while either carrier is. Wi-Fi P2P going off,
or this device leaving its group, now ends only the group's streams. No
LAN carrier exists yet, so behaviour is unchanged until the next commit.
…ed Wi-Fi network

The Android peer-stream manager now advertises and browses DNS-SD
_offlineprotocol._tcp on the Wi-Fi network it is on (NsdManager), with
the record iOS and Python publish, and dials what it finds on iOS's dial
policy: the lower address at once, the higher after five seconds, a
redial ladder, unprovable records left out. Dials are bound to the Wi-Fi
network and carry the record's addr as the address the preamble must
prove. The LAN carrier runs whenever wifiDirect.enabled is set, needs
only ACCESS_NETWORK_STATE, and keeps the slot up with Wi-Fi P2P off or
not permitted.
The stream chapter, threat model, bridge rules and integration guides
describe the Android LAN carrier and the shared duplicate-stream rule.
A browser now looks TXT entries up by key, since NsdManager does not
promise attribute order. CHANGELOG gains an Unreleased section and
UPGRADING a §27 for what changes without a compile error.
…d again

NsdManager's DiscoveryListener reports a record found and lost, never
changed, so a peer that republished its name on a new port without a
goodbye went unnoticed: nothing dialed it back, and a redial used the dead
port. The LAN carrier now dials a peer whose announced stream ended while
its record is still advertised, and re-resolves a record whose dial nothing
answered, which returns the cache's current port. Found on devices against
an advertise-only Python host: recovery after a restart now takes about ten
seconds.
…et duplicates

Before API 29, ParcelFileDescriptor.fromSocket takes the socket's own
descriptor rather than a duplicate, so closing it after setting the
keepalive options closed every peer stream on Android 7 to 9, including
Wi-Fi Direct group streams. The options are now set on Android 10 and
later only; older versions keep the OS defaults.

Keepalive also sends no probe while data waits unacknowledged, so a stale
stream with a write outstanding held its address for about fifteen
minutes, not the thirty seconds the docs gave. TCP_USER_TIMEOUT at 30 s,
the Python manager's bound, closes that case.
…ets the redial ladder

run() reports the address a preamble proved even when the stream is then
refused, here because another stream holds the address or there because
the peer's stale stream wins. Both redial ladders read that as success and
started over at one second: a Wi-Fi Direct client whose owner was held by
a LAN stream redialed it every second for as long as that stream lived,
and a dialer refused remotely was announced and lost every second.

Ran now also says whether a body was delivered, and only that resets a
ladder. A group client whose owner is held by another stream parks its
redial and dials again when that stream is lost.
…a carried stream

The iOS manager reset its dial ladder when a dialed stream's preamble
proved the address, even when the peer then refused the stream for its
own held one. With Android now keeping the lower-opened stream, an iPhone
redialing past a stream left stale on the Android side was announced and
lost every second until Android's keepalive ended that stream. The ladder
now starts over on the first body after the preamble, Android's rule, and
peer_stream_redial_ladders_reset_only_on_a_carried_stream pins both.
…S needs one

Before T extensions 7 (Android 12 and lower, and 13 without that update)
the platform drops mDNS packets for an app that holds no multicast lock,
even in the foreground, so on most of the minSdk range the LAN carrier
neither heard a peer's answers nor the queries a peer sent to find it.
The device test ran on Android 16, which manages multicast itself.

LanPeerDiscovery now holds a non-counted lock while it browses, only where
the platform needs one, and the module declares
CHANGE_WIFI_MULTICAST_STATE, an install-time permission.
…checking it

scheduleReconnect checked whether another stream held the owner's address
on a socket thread and recorded the address afterwards. A holding stream
lost in between was handled on the transport thread, found no address to
redial, and the client was parked with no stream until its group changed.
The address is now published first, so a loss either precedes the check,
which then redials, or follows it and finds the address.
…edial ladder

The ladders started over only when a body followed the preamble. Two peers
with an established session may reconnect and send nothing, since a
reconnect only flushes what is pending, so every ordinary drop of such a
stream, however long it lived, climbed the ladder toward one minute and it
never came back down. A stream that held its address for the keepalive
window (30 s) now counts as having carried its peer as well, on Android
(Ran.carried, renamed from delivered) and iOS. A stream refused right
after its proof still does not, which is what the ladder rule was for.
…ETWORK on API 37

An app targeting Android 17 needs ACCESS_LOCAL_NETWORK, a runtime grant,
to reach the local network. Without it NsdManager shows a system service
picker on every browse and LAN sockets are refused, while the docs said
the carrier needed no runtime grant. The carrier now checks the grant at
start and stays off with a warning diagnostic. The module does not declare
the permission, because a declaration revokes the grant that apps
targeting 36 and lower hold by default; the docs say an app targeting 37
must declare and request it.
A failed NSD registration or discovery start was final until the Wi-Fi
network changed, so one transient failure left the phone invisible to, or
blind to, every peer on that network for the session, while the layer was
reported up. Both are now tried again after five seconds, iOS's
REBUILD_DELAY, unless the network or the transport moved on meanwhile. A
browse that failed to start also let go of the multicast lock it took,
which stayed held with nothing browsing.
Resolves run one at a time, and the queue assumed each one answers. One
that never did held it for good: no peer found afterwards was resolved,
and a refresh after a dead port waited behind it. FAILURE_ALREADY_ACTIVE
also re-queued the same item every second without end, and an item whose
resolveService threw stalled the rest until another peer was found.

The resolve in flight is now identified by its listener, so forgetting it
(pause, a network change, the 15 s watchdog) retires a late or silent one;
the watchdog stops it on API 34+. ALREADY_ACTIVE is retried five times,
and a throw moves on to the next item.
…-Fi network only

Dials are bound to the Wi-Fi network, but the advert and the browse ran
on every interface and records were keyed by instance name. The same
instance reported on a Wi-Fi Direct group or a tethering downstream could
overwrite the Wi-Fi record with a host no bound dial reaches, and its loss
deleted the Wi-Fi record, so the peer was never dialed again on the LAN.

From API 33 the advert and the browse are scoped to the Wi-Fi network and
results from any other are ignored. Below it NSD carries no network; the
spec says so.
…is published

Before T extensions 7 the platform drops mDNS for an app without a
multicast lock, and an advert needs it as much as a browse: without it the
device does not hear the queries a peer sends to find it. The lock
followed the browse alone, so pausing (which keeps the record published)
or a failed browse waiting to retry let it go, and the device could not
be found. It is now held while the advert or the browse is active.
… on loss

More than one Wi-Fi network can match the carrier's request at once
(make-before-break switching, a local-only network another app asked
for). The carrier followed whichever network last reported a change,
tearing down NSD and every LAN stream each time, and when the one it
settled on was lost it went to no network while another was still up.
It now keeps the current network until it is lost and then adopts
another matching one.
…iled resolve

A resolve that failed, gave way to one still running, or hit the
watchdog was dropped, and NSD reports a service again only after losing
it, so that peer stayed unresolved for the session: unreachable if it
never dials this device itself, such as an advertise-only host. Each of
those paths now queues the service again after five seconds, while NSD
still reports it.
…t SDK

The API 37 gate relied on checkSelfPermission(ACCESS_LOCAL_NETWORK)
returning granted for an app targeting 36 or lower that never declares
it, the platform's default grant. The restriction itself keys off the
app's target, so the gate now passes any app targeting below 37 and
checks the grant only for those targeting 37 or later.
… service is reported

A service lost while its resolve was in flight, or put back at the head
of the queue by the ALREADY_ACTIVE wait after its loss, was still written
as a record when the answer came. NSD reports a loss once, so the record
was never removed, and its address was dialed for the rest of the
network's life. An answer is now applied, and a waiting resolve re-queued,
only while NSD still reports the service.
…rk choice are tested

LanPeerDiscovery's bookkeeping, where most of this branch's device-found
fixes landed, had no tests. The resolve queue (what NSD reports, one
resolve in flight, the ALREADY_ACTIVE wait, retiring a late or stuck
resolve, applying an answer only while the service is reported) moves
into ResolveQueue, and the choice of Wi-Fi network (keep the current one,
fall back on its loss) into NetworkChoice. Both are framework-free, as
PeerStreamDialPolicy is, and LanPeerStateTest pins each rule.
LanPeerDiscovery keeps the NsdManager calls and the timers.
@mizanxali
mizanxali requested a review from bahdotsh October 7, 2026 18:44
@bahdotsh
bahdotsh merged commit 90c3959 into main Oct 8, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 8, 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