Merge pull request #808 from moehamade/fix/hotspot-wifi-direct-reliability

fix: make Wi-Fi Direct hotspot sharing reliable
This commit is contained in:
callebtc 2026-07-29 12:33:16 +02:00 committed by GitHub
commit ff0578cb88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 511 additions and 56 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
@ -52,8 +57,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to get hotspot connection info")
failWith("Failed to get hotspot connection info")
return@launch
}
@ -75,8 +79,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to start web server: ${e.message}")
failWith("Failed to start web server: ${e.message}")
}
}
}
@ -92,17 +95,13 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
}
override fun onError(message: String) {
viewModelScope.launch {
Log.e(TAG, "Hotspot error: $message")
_state.value = HotspotState.Error(message)
}
viewModelScope.launch { failWith(message) }
}
})
} catch (e: Exception) {
Log.e(TAG, "Error starting hotspot", e)
hotspotManager?.stopHotspot()
_state.value = HotspotState.Error(e.message ?: "Unknown error")
failWith(e.message ?: "Unknown error")
}
}
}
@ -112,14 +111,33 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
teardown()
_state.value = HotspotState.Intro
}
/**
* Every failure after the hotspot has been requested must land here.
*
* Skipping any part of this leaves something running that shouldn't be: the web
* server keeps serving the APK on whatever network the device joins next, and the
* Wi-Fi Aware hold blocks the mesh until the user happens to retry or close the
* screen.
*/
private fun failWith(message: String) {
Log.e(TAG, "Hotspot failed: $message")
teardown()
_state.value = HotspotState.Error(message)
}
/** Releases every resource startHotspot may have acquired. Safe to call twice. */
private fun teardown() {
webServer?.stopServer()
webServer = null
hotspotManager?.stopHotspot()
hotspotManager = null
_state.value = HotspotState.Intro
WifiAwareController.releaseHotspotHold()
}
/**

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
@ -149,7 +179,7 @@ object WifiAwareController {
return
}
}
if (!_enabled.value) {
if (!_enabled.value || hotspotHold.get()) {
synchronized(lifecycleLock) { starting = false }
return
}
@ -159,23 +189,40 @@ object WifiAwareController {
WifiAwareMeshService(ctx)
}
startedService.startServices()
if (startedService.isRunning()) {
synchronized(lifecycleLock) {
// Test the hold inside the same lock that publishes the service, and that
// stop() takes. Testing it outside leaves a window where holdForHotspot()
// sets the flag and stop() finds nothing published yet, and this block then
// publishes anyway — resurrecting NAN while the hotspot owns the radio.
// Ordering holds because holdForHotspot() sets the flag before calling
// stop(): either we see the flag here, or stop() sees our published service.
val published = synchronized(lifecycleLock) {
val canPublish = !hotspotHold.get() && startedService.isRunning()
if (canPublish) {
service = startedService
_running.value = true
} else {
if (service === startedService) service = null
_running.value = false
}
canPublish
}
if (published) {
try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { }
clearBlockedDebugMessage()
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware started")) } catch (_: Exception) {}
} else {
if (reusableService == null) {
// stopServices() can block, so keep it out of the lock.
val heldForHotspot = hotspotHold.get()
if (heldForHotspot || reusableService == null) {
try { startedService.stopServices() } catch (_: Exception) { }
}
synchronized(lifecycleLock) {
if (service === startedService) service = null
_running.value = false
if (heldForHotspot) {
Log.i(TAG, "Abandoned Wi-Fi Aware start: hotspot claimed the radio")
}
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware did not start")) } catch (_: Exception) {}
val detail = if (heldForHotspot) "held down for the hotspot" else "did not start"
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware $detail")) } catch (_: Exception) {}
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to start WifiAwareMeshService", e)

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)
}
}