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.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager
@ -137,6 +139,12 @@ fun HotspotScreen(
is HotspotViewModel.HotspotState.Starting -> {
LoadingScreen()
}
is HotspotViewModel.HotspotState.ConfirmDisconnect -> {
ExistingGroupConfirmation(
onConfirm = viewModel::confirmDisconnectAndStart,
onCancel = viewModel::cancelDisconnect
)
}
is HotspotViewModel.HotspotState.Active -> {
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)
@Composable
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 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
private var savedSsid: String? = null
private var savedPassword: String? = null
@ -103,8 +113,13 @@ class HotspotManager(private val context: Context) {
/**
* 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) {
Log.w(TAG, "Hotspot already starting")
return
@ -129,6 +144,7 @@ class HotspotManager(private val context: Context) {
}
this.callback = callback
this.confirmedReplacementName = confirmedReplacementName
isStarting = true
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
@ -162,8 +178,12 @@ class HotspotManager(private val context: Context) {
/**
* 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")
isStarting = false
@ -177,19 +197,40 @@ class HotspotManager(private val context: Context) {
val staleChannel = channel
channel = null
if (staleChannel != null) {
wifiP2pManager?.removeGroup(staleChannel, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
// Nothing of ours is left for a later run to clean up.
ownedGroupName = null
closeChannel(staleChannel)
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
closeChannel(staleChannel)
}
})
val hadOwnGroup = createdGroup
createdGroup = false
if (staleChannel != null && hadOwnGroup) {
try {
wifiP2pManager?.removeGroup(staleChannel, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
// Nothing of ours is left for a later run to clean up.
ownedGroupName = null
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
@ -305,7 +346,8 @@ class HotspotManager(private val context: Context) {
val action = HotspotStartupPolicy.startAction(
p2pState = lastP2pState,
existingGroupName = existingGroup?.networkName,
ownedGroupName = ownedGroupName
ownedGroupName = ownedGroupName,
confirmedGroupName = confirmedReplacementName
)
when (action) {
@ -313,7 +355,19 @@ class HotspotManager(private val context: Context) {
Log.w(TAG, "Not attempting group creation: ${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 -> {
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
removeStaleGroup(ch, attempt)
@ -327,33 +381,46 @@ class HotspotManager(private val context: Context) {
}
private fun removeStaleGroup(ch: Channel, attempt: Int) {
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
if (channel !== ch) return
Log.d(TAG, "Stale group removed")
createGroup(attempt)
}
override fun onFailure(reason: Int) {
if (channel !== ch) return
// Creation may still succeed, and a BUSY reply here backs off as usual.
Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway")
createGroup(attempt)
}
})
try {
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
if (channel !== ch) return
Log.d(TAG, "Stale group removed")
createGroup(attempt, oldGroupCleared = true)
}
override fun onFailure(reason: Int) {
if (channel !== ch) return
// Creation may still succeed, and a BUSY reply here backs off as usual.
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.
*
* @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")
private fun createGroup(attempt: Int) {
private fun createGroup(attempt: Int, oldGroupCleared: Boolean) {
val ch = channel ?: return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Record before the call: if the process dies between creation and the
// first group info, the next run still knows this orphan is ours.
ownedGroupName = savedSsid
if (oldGroupCleared) {
ownedGroupName = savedSsid
}
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
@ -376,11 +443,18 @@ class HotspotManager(private val context: Context) {
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() {
if (channel !== requestChannel) {
// Ours by construction: this listener only observes our own createGroup.
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
}
Log.d(TAG, "P2P group created successfully")
createdGroup = true
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
@ -434,6 +508,17 @@ class HotspotManager(private val context: Context) {
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.
*/
@ -481,8 +566,13 @@ class HotspotManager(private val context: Context) {
savedPassword = group.passphrase
}
// Authoritative name straight from the framework
group.networkName?.let { ownedGroupName = it }
// 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) {
@ -618,6 +708,14 @@ class HotspotManager(private val context: Context) {
interface HotspotCallback {
fun onHotspotStarted()
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)
}
}

View File

@ -19,8 +19,6 @@ internal object HotspotStartupPolicy {
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 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 GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again."
@ -32,6 +30,10 @@ internal object HotspotStartupPolicy {
sealed interface StartAction {
data object Create : 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
}
@ -42,29 +44,38 @@ internal object HotspotStartupPolicy {
* hosting leaves an orphan behind. The framework rejects createGroup with BUSY
* 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
* we can show are ours get torn down.
* Wi-Fi Direct is shared with Cast, Android Auto and Quick Share. A group we can
* 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 ownedGroupName last group name this app recorded creating, or null
* @param confirmedGroupName group the user agreed to disconnect, or null
*/
fun startAction(
p2pState: Int?,
existingGroupName: String?,
ownedGroupName: String?
ownedGroupName: String?,
confirmedGroupName: String?
): StartAction = when {
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE)
existingGroupName == null -> StartAction.Create
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
* fallback, covering orphans left by builds that predate that record.
* Only the exact name this device recorded creating counts as ours. A prefix
* 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 =
existingGroupName == ownedGroupName || existingGroupName.startsWith(SSID_PREFIX)
ownedGroupName != null && existingGroupName == ownedGroupName
/**
* @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure`

View File

@ -1,6 +1,8 @@
package com.bitchat.android.hotspot
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@ -18,6 +20,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
companion object {
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)
@ -25,12 +31,25 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
private var hotspotManager: HotspotManager? = 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
/**
* Start the hotspot with the provided APK 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) {
Log.w(TAG, "Hotspot already starting or active")
return
@ -43,7 +62,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
try {
// 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.
WifiAwareController.holdForHotspot()
awareLease = WifiAwareController.acquireHotspotLease()
// Start hotspot
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) {
viewModelScope.launch { failWith(message) }
}
})
}, confirmedGroupName)
} catch (e: Exception) {
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.
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
pendingApk = null
pendingGroupName = null
teardown()
_state.value = HotspotState.Intro
}
@ -125,6 +178,8 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
*/
private fun failWith(message: String) {
Log.e(TAG, "Hotspot failed: $message")
pendingApk = null
pendingGroupName = null
teardown()
_state.value = HotspotState.Error(message)
}
@ -134,10 +189,34 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
webServer?.stopServer()
webServer = null
hotspotManager?.stopHotspot()
val manager = hotspotManager
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 {
object Intro : 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(
val ssid: 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_other">Share via Quick Share</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 -->
<string name="install_bitchat_title">Install Received APK</string>

View File

@ -76,49 +76,83 @@ class HotspotStartupPolicyTest {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-CUF6EN63",
ownedGroupName = "DIRECT-BC-CUF6EN63"
ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
)
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
}
@Test
fun `another app's group is left alone`() {
fun `another app's group requires the user's confirmation`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = "DIRECT-BC-CUF6EN63"
ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
}
@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(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-OLDGROUP",
ownedGroupName = null
ownedGroupName = null,
confirmedGroupName = null
)
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
}
@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(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = null
ownedGroupName = null,
confirmedGroupName = null
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action)
}
@Test
@ -126,18 +160,35 @@ class HotspotStartupPolicyTest {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = null,
ownedGroupName = null
ownedGroupName = null,
confirmedGroupName = null
)
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
fun `disabled P2P fails before touching any existing group`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED,
existingGroupName = "DIRECT-BC-CUF6EN63",
ownedGroupName = "DIRECT-BC-CUF6EN63"
ownedGroupName = "DIRECT-BC-CUF6EN63",
confirmedGroupName = null
)
assertEquals(