From 41544a6840e59a1dcc429e74f24e50a9aa96a42b Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:54:01 +0300 Subject: [PATCH] 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) --- .../bitchat/android/hotspot/HotspotManager.kt | 203 ++++++++++++++---- .../android/hotspot/HotspotStartupPolicy.kt | 91 ++++++++ .../android/hotspot/HotspotViewModel.kt | 10 + .../android/wifi-aware/WifiAwareController.kt | 30 +++ .../hotspot/HotspotStartupPolicyTest.kt | 170 +++++++++++++++ 5 files changed, 467 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt index 3a22b6b1..8f503fb5 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -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" } /** diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt new file mode 100644 index 00000000..48284d0a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt @@ -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) +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt index 50b04855..47ad1c5e 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index 1c2b7e5f..13cbd8fe 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -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 diff --git a/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt new file mode 100644 index 00000000..bb91b072 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt @@ -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) + } +} \ No newline at end of file