495 lines
17 KiB
Kotlin

package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
import android.net.wifi.p2p.WifiP2pManager.*
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
/**
* Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing.
* Based on Briar's implementation.
*/
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
// Give up if the group never forms within this window after creation succeeded
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
// Characters to use for random generation (excluding confusing ones)
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
}
private val wifiP2pManager: WifiP2pManager? =
context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
private var channel: Channel? = null
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
private val handler = Handler(Looper.getMainLooper())
private val random = SecureRandom()
private var currentGroup: WifiP2pGroup? = null
private var callback: HotspotCallback? = null
private var isStarting = false
private var hasNotifiedStarted = false // Track if we've notified the callback
private var isReceiverRegistered = false // Track receiver registration to prevent leaks
// Saved credentials for reconnection
private var savedSsid: String? = null
private var savedPassword: String? = null
// 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)
Log.d(TAG, "Wi-Fi P2P state changed: $state")
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
Log.d(TAG, "Wi-Fi P2P connection changed")
requestGroupInfo()
}
}
}
}
/**
* Start the Wi-Fi P2P hotspot.
*/
fun startHotspot(callback: HotspotCallback) {
if (isStarting) {
Log.w(TAG, "Hotspot already starting")
return
}
if (wifiP2pManager == null) {
Log.e(TAG, "Wi-Fi P2P not available on this device")
callback.onError("Wi-Fi Direct not supported on this device")
return
}
val missingPermissions = HotspotPermissions.missingFrom(context)
if (missingPermissions.isNotEmpty()) {
Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions")
val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) {
"Local network permission is required to share the app over the hotspot"
} else {
"Nearby Wi-Fi permission is required to start the hotspot"
}
callback.onError(message)
return
}
this.callback = callback
isStarting = true
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
// Register broadcast receiver (only if not already registered)
if (!isReceiverRegistered) {
val intentFilter = IntentFilter().apply {
addAction(WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION)
}
context.registerReceiver(broadcastReceiver, intentFilter)
isReceiverRegistered = true
Log.d(TAG, "Broadcast receiver registered")
}
// Acquire locks
acquireLocks()
// Load or generate credentials
if (savedSsid == null || savedPassword == null) {
savedSsid = generateSsid()
savedPassword = generatePassword()
Log.d(TAG, "Generated new credentials: SSID=$savedSsid")
} else {
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
}
// Start P2P framework with retries
startWifiP2pFramework(1)
}
/**
* Stop the hotspot.
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
isStarting = false
hasNotifiedStarted = false
// Stop group info polling
handler.removeCallbacksAndMessages(null)
// Remove group
channel?.let { ch ->
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
}
})
}
// Release locks
releaseLocks()
// Unregister receiver (only if registered)
if (isReceiverRegistered) {
try {
context.unregisterReceiver(broadcastReceiver)
isReceiverRegistered = false
Log.d(TAG, "Broadcast receiver unregistered")
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Receiver was not registered", e)
isReceiverRegistered = false
}
}
currentGroup = null
channel = null
callback = null
}
/**
* Get current connection information.
*/
fun getConnectionInfo(): ConnectionInfo? {
val group = currentGroup ?: return null
val ipAddress = getAccessPointAddress()
return ConnectionInfo(
ssid = group.networkName ?: savedSsid ?: "",
password = group.passphrase ?: savedPassword ?: "",
ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP
connectedPeers = group.clientList?.size ?: 0
)
}
/**
* Start Wi-Fi P2P framework with retry logic.
*/
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
}
Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
if (channel == null) {
Log.e(TAG, "Failed to initialize P2P channel")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
return
}
createGroup(attempt)
}
/**
* Create Wi-Fi P2P group.
*/
@SuppressLint("MissingPermission")
private fun createGroup(attempt: Int) {
val ch = channel ?: return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
.setPassphrase(savedPassword!!)
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
.build()
wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch))
} else {
// Android 9 and below: System-generated SSID/password
wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() {
if (channel !== requestChannel) {
Log.w(TAG, "Removing group created after hotspot was stopped")
wifiP2pManager?.removeGroup(requestChannel, null)
return
}
Log.d(TAG, "P2P group created successfully")
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
override fun onFailure(reason: Int) {
if (channel != null) {
handleGroupCreationFailure(reason, attempt)
}
}
}
/**
* Handle group creation failure with retry logic.
*/
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
val reasonStr = when (reason) {
ERROR -> "ERROR"
P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
BUSY -> "BUSY"
else -> "UNKNOWN($reason)"
}
Log.w(TAG, "Failed to create group: $reasonStr")
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")
}
}
/**
* Terminal startup failure: release all resources (locks, receiver, handler
* callbacks) before notifying the callback, so a failed attempt doesn't leak
* and block subsequent attempts.
*/
private fun failStartup(message: String) {
val cb = callback
stopHotspot()
cb?.onError(message)
}
/**
* Start polling for group info to track connected clients.
*/
private fun startGroupInfoPolling() {
requestGroupInfo()
// Keep polling even while the group info is still null — the first
// requestGroupInfo() after createGroup() can legitimately return null
// while the group is forming. Give up only after a timeout.
var elapsedMillis = 0L
handler.postDelayed(object : Runnable {
override fun run() {
if (channel == null) return
elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS
if (currentGroup == null && !hasNotifiedStarted &&
elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS
) {
Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms")
failStartup("Hotspot failed to start. Please try again.")
return
}
requestGroupInfo()
handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
}, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
/**
* Request current group information.
*/
@SuppressLint("MissingPermission")
private fun requestGroupInfo() {
val ch = channel ?: return
try {
wifiP2pManager?.requestGroupInfo(ch) { group ->
if (group != null) {
currentGroup = group
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
} else {
Log.w(TAG, "requestGroupInfo returned null group")
}
}
} 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.")
}
}
/**
* Acquire WakeLock and WifiLock to keep hotspot active.
*/
private fun acquireLocks() {
try {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"BitChat:HotspotWakeLock"
)
wakeLock?.acquire(30 * 60 * 1000L)
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
} else {
android.net.wifi.WifiManager.WIFI_MODE_FULL
}
wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock")
wifiLock?.acquire()
Log.d(TAG, "Acquired WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error acquiring locks", e)
}
}
/**
* Release WakeLock and WifiLock.
*/
private fun releaseLocks() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
wifiLock?.let {
if (it.isHeld) {
it.release()
}
}
wifiLock = null
Log.d(TAG, "Released WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error releasing locks", e)
}
}
/**
* Get the IP address of the P2P access point.
* Looks for network interface starting with "p2p".
*/
private fun getAccessPointAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
if (iface.name.startsWith("p2p")) {
val addresses = iface.interfaceAddresses
for (addr in addresses) {
val address = addr.address
// IPv4 only (4 bytes)
if (address.address.size == 4) {
return address.hostAddress
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting access point address", e)
}
return null
}
/**
* Generate random SSID.
* Format: DIRECT-BC-XXXXXXXX
*/
private fun generateSsid(): String {
val suffix = (1..SSID_SUFFIX_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
return "$SSID_PREFIX$suffix"
}
/**
* Generate random password.
* 16 characters, excluding confusing characters.
*/
private fun generatePassword(): String {
return (1..PASSWORD_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
}
/**
* Connection information for the hotspot.
*/
data class ConnectionInfo(
val ssid: String,
val password: String,
val ipAddress: String,
val connectedPeers: Int
)
/**
* Callback interface for hotspot events.
*/
interface HotspotCallback {
fun onHotspotStarted()
fun onConnectionInfoUpdated(info: ConnectionInfo?)
fun onError(message: String)
}
}