Repository navigation
feat(bindings): Android peer streams on a shared Wi-Fi network - #534
Merged
Merged
Conversation
…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.
# Conflicts: # CHANGELOG.md
bahdotsh
approved these changes
Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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._tcpon the Wi-Fi network it is on, so it reaches iPhones and Python hosts there. ADR 0028 records the decision.What changes
LanPeerDiscovery.kt):NsdManageradvertise and browse with the record iOS and Python publish (txtvers=1,addr, same instance name).addras the address the preamble must prove.ACCESS_LOCAL_NETWORK.every_peer_stream_manager_keeps_the_same_streampins all three copies.TCP_USER_TIMEOUTon Android 10 and later.CHANGELOG.mdUnreleased, UPGRADING §27.Behaviour change for apps
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.NsdManagerdoesn't promise attribute order. Every existing reader already does. On Android 16,txtverswas published first anyway.Testing
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.Ran, and the newResolveQueue/NetworkChoicestate classes.DiscoveryListenernever reports a record update.neighbor_lostonce, then reconnected about 9 s after Wi-Fi returned, with one stream.Not verified
Follow-ups, not in this PR
LanPeerDiscovery.ended().