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