fix(hotspot): only remove our own group; ask consent to replace a foreign one

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>
This commit is contained in:
Moe Hamade 2026-07-30 00:50:36 +03:00
parent 3562ad10d5
commit 141adf5468
6 changed files with 342 additions and 66 deletions

View File

@ -28,12 +28,14 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.UniversalApkManager
@ -137,6 +139,12 @@ fun HotspotScreen(
is HotspotViewModel.HotspotState.Starting -> { is HotspotViewModel.HotspotState.Starting -> {
LoadingScreen() LoadingScreen()
} }
is HotspotViewModel.HotspotState.ConfirmDisconnect -> {
ExistingGroupConfirmation(
onConfirm = viewModel::confirmDisconnectAndStart,
onCancel = viewModel::cancelDisconnect
)
}
is HotspotViewModel.HotspotState.Active -> { is HotspotViewModel.HotspotState.Active -> {
ActiveHotspotScreen(state = currentState) ActiveHotspotScreen(state = currentState)
} }
@ -152,6 +160,28 @@ fun HotspotScreen(
} }
} }
@Composable
private fun ExistingGroupConfirmation(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.hotspot_disconnect_title)) },
text = { Text(stringResource(R.string.hotspot_disconnect_message)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.hotspot_disconnect_confirm))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.cancel))
}
}
)
}
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun IntroScreen(onStartHotspot: () -> Unit) { fun IntroScreen(onStartHotspot: () -> Unit) {

View File

@ -63,6 +63,16 @@ class HotspotManager(private val context: Context) {
private var hasNotifiedStarted = false // Track if we've notified the callback private var hasNotifiedStarted = false // Track if we've notified the callback
private var isReceiverRegistered = false // Track receiver registration to prevent leaks 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).
private var createdGroup = false
// 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
// Saved credentials for reconnection // Saved credentials for reconnection
private var savedSsid: String? = null private var savedSsid: String? = null
private var savedPassword: String? = null private var savedPassword: String? = null
@ -103,8 +113,13 @@ class HotspotManager(private val context: Context) {
/** /**
* Start the Wi-Fi P2P hotspot. * Start the Wi-Fi P2P hotspot.
*
* @param confirmedReplacementName name of the foreign Wi-Fi Direct group the
* user has confirmed may be disconnected, as previously reported through
* [HotspotCallback.onExistingGroupConflict]. When null (or when the group
* present no longer matches), a foreign group is reported instead of touched.
*/ */
fun startHotspot(callback: HotspotCallback) { fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) {
if (isStarting) { if (isStarting) {
Log.w(TAG, "Hotspot already starting") Log.w(TAG, "Hotspot already starting")
return return
@ -129,6 +144,7 @@ class HotspotManager(private val context: Context) {
} }
this.callback = callback this.callback = callback
this.confirmedReplacementName = confirmedReplacementName
isStarting = true isStarting = true
Log.d(TAG, "Starting Wi-Fi P2P hotspot") Log.d(TAG, "Starting Wi-Fi P2P hotspot")
@ -162,8 +178,12 @@ class HotspotManager(private val context: Context) {
/** /**
* Stop the hotspot. * Stop the hotspot.
*
* @param onTeardownComplete invoked once the framework has acknowledged the
* removal of our group (or immediately when this session created none). Lets
* the caller hold the Wi-Fi Aware radio back until the P2P group is gone.
*/ */
fun stopHotspot() { fun stopHotspot(onTeardownComplete: (() -> Unit)? = null) {
Log.d(TAG, "Stopping hotspot") Log.d(TAG, "Stopping hotspot")
isStarting = false isStarting = false
@ -177,19 +197,40 @@ class HotspotManager(private val context: Context) {
val staleChannel = channel val staleChannel = channel
channel = null channel = null
if (staleChannel != null) { val hadOwnGroup = createdGroup
wifiP2pManager?.removeGroup(staleChannel, object : ActionListener { createdGroup = false
override fun onSuccess() {
Log.d(TAG, "Group removed successfully") if (staleChannel != null && hadOwnGroup) {
// Nothing of ours is left for a later run to clean up. try {
ownedGroupName = null wifiP2pManager?.removeGroup(staleChannel, object : ActionListener {
closeChannel(staleChannel) override fun onSuccess() {
} Log.d(TAG, "Group removed successfully")
override fun onFailure(reason: Int) { // Nothing of ours is left for a later run to clean up.
Log.w(TAG, "Failed to remove group: $reason") ownedGroupName = null
closeChannel(staleChannel) closeChannel(staleChannel)
} onTeardownComplete?.invoke()
}) }
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
closeChannel(staleChannel)
onTeardownComplete?.invoke()
}
})
} catch (e: SecurityException) {
// Revoked mid-session. Closing the channel still detaches this app's
// binder, which asks the framework to drop our group with it.
Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e)
closeChannel(staleChannel)
onTeardownComplete?.invoke()
}
} else if (staleChannel != null) {
// This session created nothing, so there is nothing of ours to remove.
// removeGroup() here is exactly the bug this change fixes: device-scoped
// removal would disconnect whatever group another app has running.
closeChannel(staleChannel)
onTeardownComplete?.invoke()
} else {
onTeardownComplete?.invoke()
} }
// Release locks // Release locks
@ -305,7 +346,8 @@ class HotspotManager(private val context: Context) {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = lastP2pState, p2pState = lastP2pState,
existingGroupName = existingGroup?.networkName, existingGroupName = existingGroup?.networkName,
ownedGroupName = ownedGroupName ownedGroupName = ownedGroupName,
confirmedGroupName = confirmedReplacementName
) )
when (action) { when (action) {
@ -313,7 +355,19 @@ class HotspotManager(private val context: Context) {
Log.w(TAG, "Not attempting group creation: ${action.message}") Log.w(TAG, "Not attempting group creation: ${action.message}")
failStartup(action.message) failStartup(action.message)
} }
HotspotStartupPolicy.StartAction.Create -> createGroup(attempt) HotspotStartupPolicy.StartAction.ConfirmReplaceExisting -> {
val name = existingGroup?.networkName
if (name == null) {
// Unreachable while the policy requires a name, but there
// is nothing safe to bind consent to without one.
failStartup(HotspotStartupPolicy.P2P_BUSY_MESSAGE)
} else {
Log.i(TAG, "Existing group '$name' is not ours; asking the user")
reportExistingGroupConflict(name)
}
}
HotspotStartupPolicy.StartAction.Create ->
createGroup(attempt, oldGroupCleared = true)
HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> { HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> {
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating") Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
removeStaleGroup(ch, attempt) removeStaleGroup(ch, attempt)
@ -327,33 +381,46 @@ class HotspotManager(private val context: Context) {
} }
private fun removeStaleGroup(ch: Channel, attempt: Int) { private fun removeStaleGroup(ch: Channel, attempt: Int) {
wifiP2pManager?.removeGroup(ch, object : ActionListener { try {
override fun onSuccess() { wifiP2pManager?.removeGroup(ch, object : ActionListener {
if (channel !== ch) return override fun onSuccess() {
Log.d(TAG, "Stale group removed") if (channel !== ch) return
createGroup(attempt) Log.d(TAG, "Stale group removed")
} createGroup(attempt, oldGroupCleared = true)
override fun onFailure(reason: Int) { }
if (channel !== ch) return override fun onFailure(reason: Int) {
// Creation may still succeed, and a BUSY reply here backs off as usual. if (channel !== ch) return
Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway") // Creation may still succeed, and a BUSY reply here backs off as usual.
createGroup(attempt) Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway")
} createGroup(attempt, oldGroupCleared = false)
}) }
})
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while removing the existing group", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
} }
/** /**
* Create Wi-Fi P2P group. * Create Wi-Fi P2P group.
*
* @param oldGroupCleared false when a previous group may still exist (its removal
* just failed). The ownership marker keeps the OLD group's name in that case:
* overwriting it early would make a BUSY retry classify our own stale group as
* foreign and raise a spurious consent dialog. On success the group-info poll
* records the authoritative name anyway.
*/ */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun createGroup(attempt: Int) { private fun createGroup(attempt: Int, oldGroupCleared: Boolean) {
val ch = channel ?: return val ch = channel ?: return
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Record before the call: if the process dies between creation and the // Record before the call: if the process dies between creation and the
// first group info, the next run still knows this orphan is ours. // first group info, the next run still knows this orphan is ours.
ownedGroupName = savedSsid if (oldGroupCleared) {
ownedGroupName = savedSsid
}
// Android 10+: Custom SSID and password // Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder() val config = WifiP2pConfig.Builder()
@ -376,11 +443,18 @@ class HotspotManager(private val context: Context) {
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener { private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() { override fun onSuccess() {
if (channel !== requestChannel) { if (channel !== requestChannel) {
// Ours by construction: this listener only observes our own createGroup.
Log.w(TAG, "Removing group created after hotspot was stopped") Log.w(TAG, "Removing group created after hotspot was stopped")
wifiP2pManager?.removeGroup(requestChannel, null) try {
wifiP2pManager?.removeGroup(requestChannel, null)
} catch (e: SecurityException) {
// The orphan stays; the next start recognises it via ownedGroupName.
Log.e(TAG, "Could not remove the late group; permission was revoked", e)
}
return return
} }
Log.d(TAG, "P2P group created successfully") Log.d(TAG, "P2P group created successfully")
createdGroup = true
isStarting = false isStarting = false
// Don't call onHotspotStarted() yet - wait for group info // Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling() startGroupInfoPolling()
@ -434,6 +508,17 @@ class HotspotManager(private val context: Context) {
cb?.onError(message) cb?.onError(message)
} }
/**
* A group belonging to another app is up. Stop cleanly — with [createdGroup]
* false the stop path leaves that group untouched — and let the UI ask whether
* starting the hotspot may disconnect it.
*/
private fun reportExistingGroupConflict(groupName: String) {
val cb = callback
stopHotspot()
cb?.onExistingGroupConflict(groupName)
}
/** /**
* Start polling for group info to track connected clients. * Start polling for group info to track connected clients.
*/ */
@ -481,8 +566,13 @@ class HotspotManager(private val context: Context) {
savedPassword = group.passphrase savedPassword = group.passphrase
} }
// Authoritative name straight from the framework // Authoritative name straight from the framework. Only recorded
group.networkName?.let { ownedGroupName = it } // 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 // Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) { if (!hasNotifiedStarted) {
@ -618,6 +708,14 @@ class HotspotManager(private val context: Context) {
interface HotspotCallback { interface HotspotCallback {
fun onHotspotStarted() fun onHotspotStarted()
fun onConnectionInfoUpdated(info: ConnectionInfo?) fun onConnectionInfoUpdated(info: ConnectionInfo?)
/**
* A Wi-Fi Direct group belonging to another app is active and the caller has
* not confirmed replacing it. Ask the user, then retry with this name as
* `confirmedReplacementName` if they accept. Nothing was disturbed.
*/
fun onExistingGroupConflict(groupName: String)
fun onError(message: String) fun onError(message: String)
} }
} }

View File

@ -19,8 +19,6 @@ internal object HotspotStartupPolicy {
const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
const val P2P_UNSUPPORTED_MESSAGE = "Wi-Fi Direct is not supported on this device." const val P2P_UNSUPPORTED_MESSAGE = "Wi-Fi Direct is not supported on this device."
const val FOREIGN_GROUP_MESSAGE =
"Another app is using Wi-Fi Direct. Close it and try again."
const val P2P_BUSY_MESSAGE = "Wi-Fi Direct is busy. Please try again in a moment." const val P2P_BUSY_MESSAGE = "Wi-Fi Direct is busy. Please try again in a moment."
const val GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again." const val GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again."
@ -32,6 +30,10 @@ internal object HotspotStartupPolicy {
sealed interface StartAction { sealed interface StartAction {
data object Create : StartAction data object Create : StartAction
data object RemoveStaleGroupThenCreate : StartAction data object RemoveStaleGroupThenCreate : StartAction
/** A group we cannot show is ours is up; ask the user before disturbing it. */
data object ConfirmReplaceExisting : StartAction
data class Fail(val message: String) : StartAction data class Fail(val message: String) : StartAction
} }
@ -42,29 +44,38 @@ internal object HotspotStartupPolicy {
* hosting leaves an orphan behind. The framework rejects createGroup with BUSY * hosting leaves an orphan behind. The framework rejects createGroup with BUSY
* while any group exists, and no amount of retrying clears it. * while any group exists, and no amount of retrying clears it.
* *
* Wi-Fi Direct is shared with Cast, Android Auto and Quick Share, so only groups * Wi-Fi Direct is shared with Cast, Android Auto and Quick Share. A group we can
* we can show are ours get torn down. * show is ours is removed silently; anything else needs the user's explicit
* go-ahead before it is touched. Consent is bound to the group it was given
* for: a group with any other name — one that appeared after the dialog, or
* mid-retry — asks again instead of riding on stale approval.
* *
* @param existingGroupName network name of the group already present, or null * @param existingGroupName network name of the group already present, or null
* @param ownedGroupName last group name this app recorded creating, or null * @param ownedGroupName last group name this app recorded creating, or null
* @param confirmedGroupName group the user agreed to disconnect, or null
*/ */
fun startAction( fun startAction(
p2pState: Int?, p2pState: Int?,
existingGroupName: String?, existingGroupName: String?,
ownedGroupName: String? ownedGroupName: String?,
confirmedGroupName: String?
): StartAction = when { ): StartAction = when {
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE) p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE)
existingGroupName == null -> StartAction.Create existingGroupName == null -> StartAction.Create
isOurs(existingGroupName, ownedGroupName) -> StartAction.RemoveStaleGroupThenCreate isOurs(existingGroupName, ownedGroupName) -> StartAction.RemoveStaleGroupThenCreate
else -> StartAction.Fail(FOREIGN_GROUP_MESSAGE) existingGroupName == confirmedGroupName -> StartAction.RemoveStaleGroupThenCreate
else -> StartAction.ConfirmReplaceExisting
} }
/** /**
* Primary signal is the name we recorded creating. The SSID prefix is only a * Only the exact name this device recorded creating counts as ours. A prefix
* fallback, covering orphans left by builds that predate that record. * match is not ownership: this device can be connected to another phone's
* bitchat group — same prefix, their suffix — and silently removing it would
* disconnect that session. An orphan predating the record simply goes through
* the confirmation dialog once.
*/ */
private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean = private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean =
existingGroupName == ownedGroupName || existingGroupName.startsWith(SSID_PREFIX) ownedGroupName != null && existingGroupName == ownedGroupName
/** /**
* @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure` * @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure`

View File

@ -1,6 +1,8 @@
package com.bitchat.android.hotspot package com.bitchat.android.hotspot
import android.app.Application import android.app.Application
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -18,6 +20,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
companion object { companion object {
private const val TAG = "HotspotViewModel" private const val TAG = "HotspotViewModel"
// Upper bound on waiting for the framework to acknowledge group removal
// before the Wi-Fi Aware lease is released anyway.
private const val TEARDOWN_FALLBACK_MILLIS = 10_000L
} }
private val _state = MutableStateFlow<HotspotState>(HotspotState.Intro) private val _state = MutableStateFlow<HotspotState>(HotspotState.Intro)
@ -25,12 +31,25 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
private var hotspotManager: HotspotManager? = null private var hotspotManager: HotspotManager? = null
private var webServer: ApkWebServer? = null private var webServer: ApkWebServer? = null
/** APK waiting on the user's answer to the disconnect confirmation. */
private var pendingApk: File? = null
/** Group the pending confirmation is about; consent binds to this name only. */
private var pendingGroupName: String? = null
/** Once-releasable radio claim owned by the current hotspot session. */
private var awareLease: WifiAwareController.HotspotLease? = null
private val context = application.applicationContext private val context = application.applicationContext
/** /**
* Start the hotspot with the provided APK file. * Start the hotspot with the provided APK file.
*/ */
fun startHotspot(apkFile: File) { fun startHotspot(apkFile: File) {
startHotspot(apkFile, confirmedGroupName = null)
}
private fun startHotspot(apkFile: File, confirmedGroupName: String?) {
if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) { if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) {
Log.w(TAG, "Hotspot already starting or active") Log.w(TAG, "Hotspot already starting or active")
return return
@ -43,7 +62,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
try { try {
// Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it // Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it
// first or every createGroup comes back BUSY. Restored when we stop. // first or every createGroup comes back BUSY. Restored when we stop.
WifiAwareController.holdForHotspot() awareLease = WifiAwareController.acquireHotspotLease()
// Start hotspot // Start hotspot
val manager = HotspotManager(context) val manager = HotspotManager(context)
@ -94,10 +113,21 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
} }
} }
override fun onExistingGroupConflict(groupName: String) {
viewModelScope.launch {
// Nothing was disturbed; the manager already stopped
// itself. Release our resources and ask the user.
teardown()
pendingApk = apkFile
pendingGroupName = groupName
_state.value = HotspotState.ConfirmDisconnect
}
}
override fun onError(message: String) { override fun onError(message: String) {
viewModelScope.launch { failWith(message) } viewModelScope.launch { failWith(message) }
} }
}) }, confirmedGroupName)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error starting hotspot", e) Log.e(TAG, "Error starting hotspot", e)
@ -106,11 +136,34 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
} }
} }
/** The user agreed that starting may disconnect the existing Wi-Fi Direct group. */
fun confirmDisconnectAndStart() {
if (_state.value !is HotspotState.ConfirmDisconnect) return
val apk = pendingApk ?: return
val groupName = pendingGroupName
pendingApk = null
pendingGroupName = null
startHotspot(apk, confirmedGroupName = groupName)
}
fun cancelDisconnect() {
// A tap landing late (e.g. through an exit animation) must not tear down
// the session a just-processed confirmation is starting.
if (_state.value !is HotspotState.ConfirmDisconnect) return
pendingApk = null
pendingGroupName = null
// The conflict path already tore down; this only covers a stray state.
teardown()
_state.value = HotspotState.Intro
}
/** /**
* Stop the hotspot and web server. * Stop the hotspot and web server.
*/ */
fun stopHotspot() { fun stopHotspot() {
Log.d(TAG, "Stopping hotspot") Log.d(TAG, "Stopping hotspot")
pendingApk = null
pendingGroupName = null
teardown() teardown()
_state.value = HotspotState.Intro _state.value = HotspotState.Intro
} }
@ -125,6 +178,8 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
*/ */
private fun failWith(message: String) { private fun failWith(message: String) {
Log.e(TAG, "Hotspot failed: $message") Log.e(TAG, "Hotspot failed: $message")
pendingApk = null
pendingGroupName = null
teardown() teardown()
_state.value = HotspotState.Error(message) _state.value = HotspotState.Error(message)
} }
@ -134,10 +189,34 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
webServer?.stopServer() webServer?.stopServer()
webServer = null webServer = null
hotspotManager?.stopHotspot() val manager = hotspotManager
hotspotManager = null hotspotManager = null
WifiAwareController.releaseHotspotHold() // Nothing of ours to hand back. An earlier teardown may already have passed
// its once-releasable lease to the manager's completion callback.
val lease = awareLease ?: return
awareLease = null
if (manager == null) {
lease.close()
return
}
// Close the lease only when the manager has finished removing our group.
// Restoring Wi-Fi Aware earlier recreates the NAN/P2P radio contention the
// lease exists to prevent. The lease is idempotent, so a duplicate or late
// completion cannot release a newer session's claim.
manager.stopHotspot { lease.close() }
// A framework that never acknowledges the removal must not pin Wi-Fi Aware
// down for the life of the process. close() is idempotent and this lease
// belongs to this session alone, so whichever path runs second is a no-op.
// A plain handler rather than viewModelScope: onCleared() cancels the scope
// right when this teardown may be running.
Handler(Looper.getMainLooper()).postDelayed(
{ lease.close() },
TEARDOWN_FALLBACK_MILLIS
)
} }
/** /**
@ -160,6 +239,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
sealed class HotspotState { sealed class HotspotState {
object Intro : HotspotState() object Intro : HotspotState()
object Starting : HotspotState() object Starting : HotspotState()
/** A foreign Wi-Fi Direct group is up; waiting for the user's go-ahead. */
object ConfirmDisconnect : HotspotState()
data class Active( data class Active(
val ssid: String, val ssid: String,
val password: String, val password: String,

View File

@ -275,6 +275,9 @@
<string name="hotspot_share_via_subtitle">Create Wi-Fi hotspot to share offline</string> <string name="hotspot_share_via_subtitle">Create Wi-Fi hotspot to share offline</string>
<string name="hotspot_share_other">Share via Quick Share</string> <string name="hotspot_share_other">Share via Quick Share</string>
<string name="hotspot_share_other_subtitle">Use standard Android sharing</string> <string name="hotspot_share_other_subtitle">Use standard Android sharing</string>
<string name="hotspot_disconnect_title">Disconnect current Wi-Fi Direct connection?</string>
<string name="hotspot_disconnect_message">Starting the hotspot will disconnect the current Wi-Fi Direct connection. This may interrupt Cast, Android Auto, Quick Share, or another nearby connection.</string>
<string name="hotspot_disconnect_confirm">Disconnect and start</string>
<!-- APK Installation --> <!-- APK Installation -->
<string name="install_bitchat_title">Install Received APK</string> <string name="install_bitchat_title">Install Received APK</string>

View File

@ -76,49 +76,83 @@ class HotspotStartupPolicyTest {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-CUF6EN63", existingGroupName = "DIRECT-BC-CUF6EN63",
ownedGroupName = "DIRECT-BC-CUF6EN63" ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
) )
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
} }
@Test @Test
fun `another app's group is left alone`() { fun `another app's group requires the user's confirmation`() {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast", existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = "DIRECT-BC-CUF6EN63" ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
) )
assertEquals( assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
} }
@Test @Test
fun `an orphan from before we recorded ownership is still recognised by its prefix`() { fun `a confirmed replacement removes the confirmed foreign group`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = null,
confirmedGroupName = "DIRECT-xY-Chromecast"
)
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
}
@Test
fun `consent for one group does not authorize removing a different one`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-aB-QuickShare",
ownedGroupName = null,
confirmedGroupName = "DIRECT-xY-Chromecast"
)
assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
}
@Test
fun `an unrecorded orphan needs confirmation even with our prefix`() {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-OLDGROUP", existingGroupName = "DIRECT-BC-OLDGROUP",
ownedGroupName = null ownedGroupName = null,
confirmedGroupName = null
) )
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
} }
@Test @Test
fun `a foreign group is left alone even when we recorded nothing`() { fun `another phone's bitchat group is not treated as ours by its prefix`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-THEIRS99",
ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
)
assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
}
@Test
fun `a foreign group needs confirmation even when we recorded nothing`() {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast", existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = null ownedGroupName = null,
confirmedGroupName = null
) )
assertEquals( assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
} }
@Test @Test
@ -126,18 +160,35 @@ class HotspotStartupPolicyTest {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = null, existingGroupName = null,
ownedGroupName = null ownedGroupName = null,
confirmedGroupName = null
) )
assertEquals(HotspotStartupPolicy.StartAction.Create, action) assertEquals(HotspotStartupPolicy.StartAction.Create, action)
} }
@Test
fun `confirmation does not skip the disabled-P2P check`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = null,
confirmedGroupName = "DIRECT-xY-Chromecast"
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE),
action
)
}
@Test @Test
fun `disabled P2P fails before touching any existing group`() { fun `disabled P2P fails before touching any existing group`() {
val action = HotspotStartupPolicy.startAction( val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED, p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED,
existingGroupName = "DIRECT-BC-CUF6EN63", existingGroupName = "DIRECT-BC-CUF6EN63",
ownedGroupName = "DIRECT-BC-CUF6EN63" ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
) )
assertEquals( assertEquals(