fix(hotspot): confirm a group is ours before trusting or removing it

Two related ways the app could act on a group it did not create.

createdGroup was set once and never revisited, so it outlived the group it
described. Our group can disappear without us - Wi-Fi toggled, another app
issuing its own device-scoped removeGroup(), a driver reset - and another
app can create one in its place while the sharing screen is still open.
Stop then believed the group present was ours and removed it, disconnecting
a session the user never agreed to touch. Every group snapshot now
reconciles the flag against what is actually on the framework.

isGroupOwner cannot carry that judgement on its own: it reports that this
DEVICE hosts the group, which is equally true of an autonomous group
another app created here. So a replacement group was still being adopted -
its name persisted as ownedGroupName, its credentials shown as ours below Q
- and because ownership is now an exact-name match, the next start
classified that name as ours and removed it with no confirmation. Snapshots
are only read once the group is confirmed to be the one we created: above Q
by the network name we chose, below Q by the first name the framework
reported for our own successful creation, fixed from then on.

Reconciliation waits until our group has been named, because a null
snapshot during formation says nothing about a group that has not appeared
yet. Losing the flag to a transient null is safe in a way that keeping it
is not - the group we created is left behind, and the next start recognises
it by name and removes it silently.

A group-info reply arriving after the stop is now ignored, so it cannot
revive state the stop just cleared.

Device-verified on a Pixel 9a (Android 16, API 37).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moe Hamade 2026-07-30 02:23:51 +03:00
parent 141adf5468
commit 5859ac29cd

View File

@ -63,12 +63,17 @@ class HotspotManager(private val context: Context) {
private var hasNotifiedStarted = false // Track if we've notified the callback
private var isReceiverRegistered = false // Track receiver registration to prevent leaks
// Set once our own createGroup command is accepted. stopHotspot() only calls
// removeGroup() when this is true: removal is device-scoped, so issuing it with
// nothing of ours on the framework could only tear down another app's session
// (Cast, Android Auto, Quick Share).
// Set once our own createGroup command is accepted, and re-checked against every
// group snapshot afterwards. stopHotspot() only calls removeGroup() when this is
// true: removal is device-scoped, so issuing it when the group on the framework
// is not ours could only tear down another app's session (Cast, Android Auto,
// Quick Share).
private var createdGroup = false
// Framework-reported name of the group this session hosts, once known. Null while
// the group is still forming, when a null snapshot carries no information.
private var hostedGroupName: String? = null
// Name of the foreign group the user explicitly agreed to disconnect, or null.
// Consent is per-group: a group with a different name asks again.
private var confirmedReplacementName: String? = null
@ -199,6 +204,7 @@ class HotspotManager(private val context: Context) {
val hadOwnGroup = createdGroup
createdGroup = false
hostedGroupName = null
if (staleChannel != null && hadOwnGroup) {
try {
@ -557,34 +563,52 @@ class HotspotManager(private val context: Context) {
try {
wifiP2pManager?.requestGroupInfo(ch) { group ->
if (group != null) {
currentGroup = group
// A reply arriving after the hotspot stopped must not revive any
// state the stop just cleared.
if (channel !== ch) return@requestGroupInfo
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
reconcileGroupOwnership(group)
// Authoritative name straight from the framework. Only recorded
// for a group we host: in the client role the group is another
// app's, and recording its name would make the next run treat a
// foreign orphan as ours.
if (group.isGroupOwner) {
group.networkName?.let { ownedGroupName = it }
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
} else {
if (group == null) {
Log.w(TAG, "requestGroupInfo returned null group")
return@requestGroupInfo
}
if (!isOurHostedGroup(group)) {
// Someone else's group is on the radio. Reading anything from it
// — its name, its credentials, its client count — would report
// another app's session as our hotspot, and recording its name
// would let the next start remove it without asking.
Log.w(
TAG,
"Observed group '${group.networkName}' is not the one we created"
)
return@requestGroupInfo
}
currentGroup = group
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Authoritative name straight from the framework, for the group we
// just confirmed is ours.
group.networkName?.let {
hostedGroupName = it
ownedGroupName = it
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
}
} catch (e: SecurityException) {
@ -593,6 +617,53 @@ class HotspotManager(private val context: Context) {
}
}
/**
* Is this snapshot the group this session created?
*
* `isGroupOwner` cannot answer that on its own: it reports that *this device*
* hosts the group, which is equally true of an autonomous group another app
* created here. Above Q we chose the network name, so it identifies our group
* exactly. Below Q the framework names it, and the first snapshot after our own
* createGroup succeeded is the only evidence available after that the name is
* fixed, and a group answering to a different one is not ours.
*/
private fun isOurHostedGroup(group: WifiP2pGroup): Boolean {
if (!group.isGroupOwner) return false
val name = group.networkName ?: return false
hostedGroupName?.let { return name == it }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
name == savedSsid
} else {
true
}
}
/**
* Keep [createdGroup] honest about what is actually on the framework.
*
* Our group can disappear without us Wi-Fi toggled, another app issuing its own
* device-scoped removeGroup(), a driver reset and another app can then create
* one in its place. Believing the group present is still ours would make stop
* remove that replacement, the exact disruption consent exists to prevent.
*
* Reconciled only once the framework has named our group: before that a null
* snapshot means the group is still forming, not that it is gone. Losing the flag
* to a transient null is safe in a way that keeping it is not the group we
* created is then left behind, and the next start recognises it by name and
* removes it silently.
*/
private fun reconcileGroupOwnership(group: WifiP2pGroup?) {
val hosted = hostedGroupName ?: return
val stillOurs = group != null && isOurHostedGroup(group)
if (createdGroup && !stillOurs) {
Log.w(TAG, "Group '$hosted' is no longer ours; leaving what is present alone")
}
createdGroup = stillOurs
}
/**
* Acquire WakeLock and WifiLock to keep hotspot active.
*/