fix: make Wi-Fi Direct hotspot sharing reliable

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>
This commit is contained in:
Moe Hamade 2026-07-29 12:54:01 +03:00
parent bd29257b73
commit 41544a6840
5 changed files with 467 additions and 37 deletions

View File

@ -28,10 +28,6 @@ class HotspotManager(private val context: Context) {
companion object {
private const val TAG = "HotspotMgr"
// Retry configuration
private const val MAX_FRAMEWORK_ATTEMPTS = 5
private const val RETRY_DELAY_MILLIS = 1000L
// Group info polling interval
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
@ -39,10 +35,14 @@ class HotspotManager(private val context: Context) {
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
// SSID and password configuration
private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
private const val SSID_SUFFIX_LENGTH = 8
private const val PASSWORD_LENGTH = 16
// Records the group we created so a later run can tell our own orphan apart
// from a group belonging to Cast, Android Auto or Quick Share.
private const val PREFS_NAME = "hotspot"
private const val KEY_OWNED_GROUP = "owned_group_name"
// Characters to use for random generation (excluding confusing ones)
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
}
@ -67,13 +67,31 @@ class HotspotManager(private val context: Context) {
private var savedSsid: String? = null
private var savedPassword: String? = null
// Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives
private var lastP2pState: Int? = null
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
/** Network name of the last group this app created, surviving process death. */
private var ownedGroupName: String?
get() = prefs.getString(KEY_OWNED_GROUP, null)
set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply()
// Broadcast receiver for Wi-Fi P2P events
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
lastP2pState = state
Log.d(TAG, "Wi-Fi P2P state changed: $state")
// Wi-Fi Direct going away is terminal for this session: without it
// the group cannot form, and any group already up is now dead.
if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) {
Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot")
failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
Log.d(TAG, "Wi-Fi P2P connection changed")
@ -138,8 +156,8 @@ class HotspotManager(private val context: Context) {
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
}
// Start P2P framework with retries
startWifiP2pFramework(1)
// Start P2P framework (retries reuse this one channel)
startWifiP2pFramework()
}
/**
@ -154,14 +172,22 @@ class HotspotManager(private val context: Context) {
// Stop group info polling
handler.removeCallbacksAndMessages(null)
// Remove group
channel?.let { ch ->
wifiP2pManager?.removeGroup(ch, object : ActionListener {
// Detach the channel first so any in-flight listener sees the hotspot as stopped,
// then remove the group and close the channel once the framework has replied.
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)
}
})
}
@ -182,10 +208,25 @@ class HotspotManager(private val context: Context) {
}
currentGroup = null
channel = null
callback = null
}
/**
* Release the channel's binder registration with WifiP2pService. Without this the
* registration survives until the process dies, and every start/stop cycle adds
* another stale client to the framework's list.
*/
private fun closeChannel(channelToClose: Channel) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return
try {
channelToClose.close()
Log.d(TAG, "P2P channel closed")
} catch (e: Exception) {
Log.w(TAG, "Error closing P2P channel", e)
}
}
/**
* Get current connection information.
*/
@ -202,28 +243,103 @@ class HotspotManager(private val context: Context) {
}
/**
* Start Wi-Fi P2P framework with retry logic.
* Initialise the P2P framework once. Every retry reuses this channel calling
* initialize() per attempt registers a fresh binder with WifiP2pService that is
* never reclaimed until the process dies.
*/
private fun startWifiP2pFramework(attempt: Int) {
if (attempt > MAX_FRAMEWORK_ATTEMPTS) {
Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts")
failStartup("Failed to start hotspot. Please try again.")
return
}
private fun startWifiP2pFramework() {
Log.d(TAG, "Initialising P2P channel")
Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
if (channel == null) {
if (newChannel == null) {
// The service is unobtainable; retrying will not change that.
Log.e(TAG, "Failed to initialize P2P channel")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE)
return
}
createGroup(attempt)
channel = newChannel
createGroupWhenP2pAvailable()
}
/**
* Ask the framework for the current P2P state before the first attempt.
*
* When P2P is disabled the state machine answers every createGroup with BUSY
* the same code a genuinely transient collision returns so without this check
* a permanent failure is indistinguishable from a retryable one.
*/
@SuppressLint("MissingPermission")
private fun createGroupWhenP2pAvailable() {
val ch = channel ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
clearStaleGroupThenCreate(ch, attempt = 1)
return
}
try {
wifiP2pManager?.requestP2pState(ch) { state ->
if (channel !== ch) return@requestP2pState
lastP2pState = state
clearStaleGroupThenCreate(ch, attempt = 1)
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
/**
* A P2P group survives the process that created it, so a previous session killed
* while hosting leaves an orphan behind. The framework then rejects createGroup
* with BUSY for as long as that group exists, which no retry can clear.
*/
@SuppressLint("MissingPermission")
private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) {
try {
wifiP2pManager?.requestGroupInfo(ch) { existingGroup ->
if (channel !== ch) return@requestGroupInfo
val action = HotspotStartupPolicy.startAction(
p2pState = lastP2pState,
existingGroupName = existingGroup?.networkName,
ownedGroupName = ownedGroupName
)
when (action) {
is HotspotStartupPolicy.StartAction.Fail -> {
Log.w(TAG, "Not attempting group creation: ${action.message}")
failStartup(action.message)
}
HotspotStartupPolicy.StartAction.Create -> createGroup(attempt)
HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> {
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
removeStaleGroup(ch, attempt)
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
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)
}
})
}
/**
@ -235,6 +351,10 @@ class HotspotManager(private val context: Context) {
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
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
@ -274,7 +394,7 @@ class HotspotManager(private val context: Context) {
}
/**
* Handle group creation failure with retry logic.
* Handle group creation failure, backing off only for genuinely transient causes.
*/
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
val reasonStr = when (reason) {
@ -284,16 +404,22 @@ class HotspotManager(private val context: Context) {
else -> "UNKNOWN($reason)"
}
Log.w(TAG, "Failed to create group: $reasonStr")
Log.w(
TAG,
"Failed to create group: $reasonStr " +
"(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)"
)
if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) {
// Framework is busy, retry
Log.d(TAG, "P2P framework busy, retrying...")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
} else {
failStartup("Failed to create hotspot: $reasonStr")
when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) {
is HotspotStartupPolicy.Decision.Retry -> {
Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms")
handler.postDelayed({
// Re-check for a stale group each round: BUSY is also how the
// framework reports "a group already exists".
channel?.let { clearStaleGroupThenCreate(it, attempt + 1) }
}, decision.delayMillis)
}
is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message)
}
}
@ -355,6 +481,9 @@ class HotspotManager(private val context: Context) {
savedPassword = group.passphrase
}
// Authoritative name straight from the framework
group.networkName?.let { ownedGroupName = it }
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
@ -460,7 +589,7 @@ class HotspotManager(private val context: Context) {
val suffix = (1..SSID_SUFFIX_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
return "$SSID_PREFIX$suffix"
return "${HotspotStartupPolicy.SSID_PREFIX}$suffix"
}
/**

View File

@ -0,0 +1,91 @@
package com.bitchat.android.hotspot
import android.net.wifi.p2p.WifiP2pManager
/**
* Decides how to react to a Wi-Fi P2P group-creation failure.
*
* Kept free of Android dependencies so the retry strategy is unit testable.
*/
internal object HotspotStartupPolicy {
const val MAX_ATTEMPTS = 5
const val INITIAL_RETRY_DELAY_MILLIS = 1_000L
const val MAX_RETRY_DELAY_MILLIS = 8_000L
const val P2P_DISABLED_MESSAGE =
"Wi-Fi Direct is unavailable. Turn Wi-Fi off and back on, then try again."
/** Marks groups this app creates. Shared with [HotspotManager] so the two cannot drift. */
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."
sealed interface Decision {
data class Retry(val delayMillis: Long) : Decision
data class Fail(val message: String) : Decision
}
sealed interface StartAction {
data object Create : StartAction
data object RemoveStaleGroupThenCreate : StartAction
data class Fail(val message: String) : StartAction
}
/**
* Decides what to do before the first group-creation attempt.
*
* A P2P group outlives the process that created it, so an app killed while
* 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.
*
* @param existingGroupName network name of the group already present, or null
* @param ownedGroupName last group name this app recorded creating, or null
*/
fun startAction(
p2pState: Int?,
existingGroupName: String?,
ownedGroupName: 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)
}
/**
* Primary signal is the name we recorded creating. The SSID prefix is only a
* fallback, covering orphans left by builds that predate that record.
*/
private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean =
existingGroupName == ownedGroupName || existingGroupName.startsWith(SSID_PREFIX)
/**
* @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure`
* @param attempt 1-based attempt that just failed
* @param p2pState last known [WifiP2pManager.EXTRA_WIFI_STATE], or null if no
* state broadcast has arrived yet
*/
fun decide(reason: Int, attempt: Int, p2pState: Int?): Decision = when {
reason == WifiP2pManager.P2P_UNSUPPORTED -> Decision.Fail(P2P_UNSUPPORTED_MESSAGE)
reason != WifiP2pManager.BUSY -> Decision.Fail(GENERIC_FAILURE_MESSAGE)
// BUSY is the framework's catch-all reply when the P2P state machine is
// disabled, so retrying cannot help — surface something actionable instead.
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> Decision.Fail(P2P_DISABLED_MESSAGE)
attempt >= MAX_ATTEMPTS -> Decision.Fail(P2P_BUSY_MESSAGE)
else -> Decision.Retry(retryDelayMillis(attempt))
}
private fun retryDelayMillis(attempt: Int): Long =
(INITIAL_RETRY_DELAY_MILLIS shl (attempt - 1)).coerceAtMost(MAX_RETRY_DELAY_MILLIS)
}

View File

@ -4,6 +4,7 @@ import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.wifiaware.WifiAwareController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -40,6 +41,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
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()
// Start hotspot
val manager = HotspotManager(context)
hotspotManager = manager
@ -94,6 +99,9 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
override fun onError(message: String) {
viewModelScope.launch {
Log.e(TAG, "Hotspot error: $message")
// The manager has already torn itself down; give the mesh
// its radio back rather than holding it for a dead hotspot.
WifiAwareController.releaseHotspotHold()
_state.value = HotspotState.Error(message)
}
}
@ -119,6 +127,8 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
hotspotManager?.stopHotspot()
hotspotManager = null
WifiAwareController.releaseHotspotHold()
_state.value = HotspotState.Intro
}

View File

@ -30,6 +30,15 @@ object WifiAwareController {
private var awareReceiverRegistered = false
private var lastBlockedReason: String? = null
/**
* Set while a Wi-Fi Direct hotspot is hosting. Wi-Fi Aware (NAN) and Wi-Fi Direct
* (P2P) cannot hold interfaces at the same time on common chipsets the HAL fails
* to create the P2P iface and every createGroup is answered with BUSY. The hold
* also blocks [startIfPossible], so a resume or mesh-service restart cannot bring
* Aware back while the hotspot is up.
*/
private val hotspotHold = AtomicBoolean(false)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _enabled = MutableStateFlow(false)
@ -87,9 +96,30 @@ object WifiAwareController {
if (value) startIfPossible() else stop()
}
/**
* Releases the Wi-Fi radio so a Wi-Fi Direct hotspot can create its P2P interface,
* and prevents Aware restarting until [releaseHotspotHold] is called.
*/
fun holdForHotspot() {
if (!hotspotHold.compareAndSet(false, true)) return
Log.i(TAG, "Holding Wi-Fi Aware down so the hotspot can use the radio")
stop()
}
/** Drops the hold and restores Aware if the user still has it enabled. */
fun releaseHotspotHold() {
if (!hotspotHold.compareAndSet(true, false)) return
Log.i(TAG, "Hotspot finished; restoring Wi-Fi Aware if enabled")
restartIfStillEnabled()
}
fun startIfPossible() {
val reusableService = synchronized(lifecycleLock) {
if (!_enabled.value) return
if (hotspotHold.get()) {
Log.d(TAG, "Not starting Wi-Fi Aware: held down for the hotspot")
return
}
val existing = service
if (existing?.isRunning() == true) {
_running.value = true

View File

@ -0,0 +1,170 @@
package com.bitchat.android.hotspot
import android.net.wifi.p2p.WifiP2pManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class HotspotStartupPolicyTest {
@Test
fun `busy while P2P is disabled fails immediately instead of retrying`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.BUSY,
attempt = 1,
p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED
)
assertEquals(
HotspotStartupPolicy.Decision.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE),
decision
)
}
@Test
fun `busy before the P2P state is known still retries`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.BUSY,
attempt = 1,
p2pState = null
)
assertTrue(decision is HotspotStartupPolicy.Decision.Retry)
}
@Test
fun `busy retry delays back off exponentially`() {
val delays = (1..4).map { attempt ->
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.BUSY,
attempt = attempt,
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED
)
(decision as HotspotStartupPolicy.Decision.Retry).delayMillis
}
assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L), delays)
}
@Test
fun `busy on the final attempt gives up`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.BUSY,
attempt = HotspotStartupPolicy.MAX_ATTEMPTS,
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED
)
assertTrue(decision is HotspotStartupPolicy.Decision.Fail)
}
@Test
fun `unsupported P2P never retries`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.P2P_UNSUPPORTED,
attempt = 1,
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED
)
assertEquals(
HotspotStartupPolicy.Decision.Fail(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE),
decision
)
}
@Test
fun `a group we recorded creating is removed before a new one is created`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-CUF6EN63",
ownedGroupName = "DIRECT-BC-CUF6EN63"
)
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
}
@Test
fun `another app's group is left alone`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = "DIRECT-BC-CUF6EN63"
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
}
@Test
fun `an orphan from before we recorded ownership is still recognised by its prefix`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-BC-OLDGROUP",
ownedGroupName = null
)
assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action)
}
@Test
fun `a foreign group is left alone even when we recorded nothing`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = "DIRECT-xY-Chromecast",
ownedGroupName = null
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE),
action
)
}
@Test
fun `creation proceeds directly when no group exists`() {
val action = HotspotStartupPolicy.startAction(
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED,
existingGroupName = null,
ownedGroupName = null
)
assertEquals(HotspotStartupPolicy.StartAction.Create, 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"
)
assertEquals(
HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE),
action
)
}
@Test
fun `busy is retryable so a group orphaned mid-session can be cleared`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.BUSY,
attempt = 1,
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED
)
assertEquals(HotspotStartupPolicy.Decision.Retry(1_000L), decision)
}
@Test
fun `generic framework error never retries`() {
val decision = HotspotStartupPolicy.decide(
reason = WifiP2pManager.ERROR,
attempt = 1,
p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED
)
assertTrue(decision is HotspotStartupPolicy.Decision.Fail)
}
}