removeGroup() is device-scoped: it removes whatever Wi-Fi Direct group
exists, including one owned by Cast, Android Auto or Quick Share.
stopHotspot() called it unconditionally, so the path built to protect a
foreign group tore that group down anyway. Removal on stop is now gated on
a createdGroup flag, set once our own createGroup command is accepted; with
nothing of ours on the framework, stop closes the channel and leaves the
group alone.
When a group we did not record creating is active at start, the app no
longer guesses about ownership - it asks. A confirmation dialog explains
that starting will disconnect the current Wi-Fi Direct connection;
confirming retries the start with replacement authorized, cancelling
leaves everything untouched. Consent is bound to the group it was given
for: the conflicting group's name travels through the dialog, and the
policy only authorizes removing a group with exactly that name - one that
appeared later, or swapped in mid-retry, re-prompts instead of riding on
stale approval.
Because consent replaces ownership proof, the DIRECT-BC- prefix heuristic
is gone: a prefix match is not ownership (this device can be connected to
another phone's bitchat group), so only the exact recorded name counts.
The record is also kept honest: never taken from a group we do not host,
and never overwritten while an old group of ours may still exist, so a
BUSY retry cannot misclassify our own stale group as foreign.
Also: SecurityException guards on the removeGroup() sites reached from
framework callbacks (permission revoked mid-session crashed instead of
failing cleanly); stopHotspot() takes a completion callback so the
ViewModel releases its Wi-Fi Aware lease only after the framework
acknowledges the removal, with an idempotent 10s fallback so a dropped
acknowledgement cannot pin the mesh down; and the confirm/cancel handlers
guard on the ConfirmDisconnect state so a tap landing through a screen
transition cannot tear down a just-confirmed session.
Replaces the state-machine approach of #811 - same protection at
proportionate cost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes to how Wi-Fi Aware yields the radio to the Wi-Fi Direct hotspot.
A restart request could be swallowed: restartIfStillEnabled() coalesces on
an in-flight flag, so a request arriving while an earlier loop was still
burning attempts against the hotspot hold lost the CAS and was dropped.
The loop then exhausted its attempts without ever seeing the cleared hold,
leaving the mesh down despite the user's setting. Requests are now recorded
before coalescing and re-checked after each pass.
The hold itself was a single flag, so overlapping share sessions could
release each other's claim. It is now a counted, once-releasable
HotspotLease: Aware restarts only when no session still needs the radio,
and a duplicate or late release is a no-op. Publication of a started
service is checked against the hold inside the same lock stop() takes, so
a start racing a new hold cannot resurrect NAN while the hotspot owns the
radio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting the APK-sharing hotspot failed intermittently with
"Failed to create hotspot: BUSY", sometimes for minutes, then
succeeded for no apparent reason. Two distinct causes, both
confirmed against a Pixel 9a via dumpsys and HAL logs.
1. Wi-Fi Aware holds the radio. The mesh's NAN interface and
Wi-Fi Direct's P2P interface cannot coexist on common chipsets:
HalDevMgr: bestIfaceCreationProposal is null, requestIface=P2P,
existingIface=[name=wlan0 type=STA, name=aware_nmi0 type=NAN]
WifiP2pNative: Failed to create P2p iface
The P2P state machine then stays in P2pDisabledState and answers
every createGroup with BUSY, while still broadcasting
WIFI_P2P_STATE_ENABLED. Whether sharing worked came down to
whether Aware happened to be attached, which is what made it look
random. WifiAwareController now releases Aware for the duration of
the hotspot and blocks restarts until it finishes.
2. Orphaned groups. A P2P group outlives the process that created
it, so a crash or swipe-away while hosting leaves one behind, and
the framework answers BUSY for as long as it exists. Startup now
removes a stale group first, but only one it can show is ours --
Wi-Fi Direct is shared with Cast, Android Auto and Quick Share.
Ownership is the group name we recorded creating, with the SSID
prefix as a fallback for orphans from older builds.
Also fixed while tracing these:
- Channel leak: initialize() ran on every retry attempt and the
channel was never closed, leaving a binder registration with
WifiP2pService per attempt. Observed climbing to 7 stale clients.
It is now initialised once and closed after removeGroup replies.
- BUSY is the framework's catch-all reply, so retrying was futile
for permanent causes and too impatient for real contention.
Retries now back off 1s/2s/4s/8s and only for genuinely transient
failures; P2P being off fails immediately with a message that says
so rather than 15 seconds ending in "busy".
- Turning Wi-Fi off mid-session left the UI showing an active
hotspot forever; it now aborts cleanly.
Retry and startup decisions are extracted into HotspotStartupPolicy,
which has no Android dependencies and is covered by 13 unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID (256), but the
generic send path fragmented packets with no caller cap (0xFFFF), so
broadcast file transfers above ~120 KB were fully transmitted yet
undeliverable. FragmentingPacketSender now caps fragmentation at
MAX_FRAGMENTS_PER_ID and reports failure via a new
TransferProgressEvent.failed flag, which surfaces as
DeliveryStatus.Failed in the UI and as a file_send error in the debug
test hook instead of an indefinite wait.
Adds FragmentingPacketSenderTest and a file_oversize mesh-lab scenario
asserting sender-side rejection.