mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
Merge pull request #808 from moehamade/fix/hotspot-wifi-direct-reliability
fix: make Wi-Fi Direct hotspot sharing reliable
This commit is contained in:
commit
ff0578cb88
@ -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
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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("Wi‑Fi 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("Wi‑Fi 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("Wi‑Fi Aware $detail")) } catch (_: Exception) {}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to start WifiAwareMeshService", e)
|
||||
|
||||
@ -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