mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
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:
parent
bd29257b73
commit
41544a6840
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user