479 lines
21 KiB
Kotlin
479 lines
21 KiB
Kotlin
package be.mygod.vpnhotspot
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.annotation.TargetApi
|
|
import android.app.Service
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.content.res.Configuration
|
|
import android.net.wifi.WpsInfo
|
|
import android.net.wifi.p2p.*
|
|
import android.os.Build
|
|
import android.os.Looper
|
|
import android.provider.Settings
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.annotation.StringRes
|
|
import androidx.core.content.edit
|
|
import androidx.core.content.getSystemService
|
|
import be.mygod.vpnhotspot.App.Companion.app
|
|
import be.mygod.vpnhotspot.net.MacAddressCompat
|
|
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
|
import be.mygod.vpnhotspot.net.wifi.P2pSupplicantConfiguration
|
|
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
|
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
|
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestPersistentGroupInfo
|
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
|
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps
|
|
import be.mygod.vpnhotspot.util.*
|
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
|
import kotlinx.coroutines.*
|
|
import timber.log.Timber
|
|
import java.lang.reflect.InvocationTargetException
|
|
import java.net.NetworkInterface
|
|
|
|
/**
|
|
* Service for handling Wi-Fi P2P. `supported` must be checked before this service is started otherwise it would crash.
|
|
*/
|
|
class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListener,
|
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
|
companion object {
|
|
const val KEY_SAFE_MODE = "service.repeater.safeMode"
|
|
private const val KEY_LAST_MAC = "service.repeater.lastMac"
|
|
|
|
private const val KEY_NETWORK_NAME = "service.repeater.networkName"
|
|
private const val KEY_PASSPHRASE = "service.repeater.passphrase"
|
|
private const val KEY_OPERATING_BAND = "service.repeater.band"
|
|
private const val KEY_OPERATING_CHANNEL = "service.repeater.oc"
|
|
private const val KEY_DEVICE_ADDRESS = "service.repeater.mac"
|
|
/**
|
|
* Placeholder for bypassing networkName check.
|
|
*/
|
|
private const val PLACEHOLDER_NETWORK_NAME = "DIRECT-00-VPNHotspot"
|
|
|
|
/**
|
|
* This is only a "ServiceConnection" to system service and its impact on system is minimal.
|
|
*/
|
|
private val p2pManager: WifiP2pManager? by lazy {
|
|
try {
|
|
app.getSystemService<WifiP2pManager>()
|
|
} catch (e: RuntimeException) {
|
|
Timber.w(e)
|
|
null
|
|
}
|
|
}
|
|
val supported get() = p2pManager != null
|
|
var persistentSupported = false
|
|
|
|
@delegate:TargetApi(29)
|
|
private val hasP2pValidateName by lazy {
|
|
val (y, m, _) = Build.VERSION.SECURITY_PATCH.split('-', limit = 3).map { it.toInt() }
|
|
y > 2020 || y == 2020 && m >= 3
|
|
}
|
|
val safeModeConfigurable get() = Build.VERSION.SDK_INT >= 29 && hasP2pValidateName
|
|
val safeMode get() = Build.VERSION.SDK_INT >= 29 &&
|
|
(!hasP2pValidateName || app.pref.getBoolean(KEY_SAFE_MODE, true))
|
|
var lastMac: String?
|
|
get() = app.pref.getString(KEY_LAST_MAC, null)
|
|
set(value) = app.pref.edit { putString(KEY_LAST_MAC, value) }
|
|
|
|
var networkName: String?
|
|
get() = app.pref.getString(KEY_NETWORK_NAME, null)
|
|
set(value) = app.pref.edit { putString(KEY_NETWORK_NAME, value) }
|
|
var passphrase: String?
|
|
get() = app.pref.getString(KEY_PASSPHRASE, null)
|
|
set(value) = app.pref.edit { putString(KEY_PASSPHRASE, value) }
|
|
var operatingBand: Int
|
|
@SuppressLint("InlinedApi") get() = app.pref.getInt(KEY_OPERATING_BAND, WifiP2pConfig.GROUP_OWNER_BAND_AUTO)
|
|
set(value) = app.pref.edit { putInt(KEY_OPERATING_BAND, value) }
|
|
var operatingChannel: Int
|
|
get() {
|
|
val result = app.pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0
|
|
return if (result in 1..165) result else 0
|
|
}
|
|
set(value) = app.pref.edit { putString(KEY_OPERATING_CHANNEL, value.toString()) }
|
|
var deviceAddress: MacAddressCompat?
|
|
get() = try {
|
|
MacAddressCompat(app.pref.getLong(KEY_DEVICE_ADDRESS, MacAddressCompat.ANY_ADDRESS.addr)).run {
|
|
validate()
|
|
if (this == MacAddressCompat.ANY_ADDRESS) null else this
|
|
}
|
|
} catch (e: IllegalArgumentException) {
|
|
Timber.w(e)
|
|
null
|
|
}
|
|
set(value) = app.pref.edit { putLong(KEY_DEVICE_ADDRESS, (value ?: MacAddressCompat.ANY_ADDRESS).addr) }
|
|
}
|
|
|
|
enum class Status {
|
|
IDLE, STARTING, ACTIVE, DESTROYED
|
|
}
|
|
|
|
inner class Binder : android.os.Binder() {
|
|
val service get() = this@RepeaterService
|
|
val active get() = status == Status.ACTIVE
|
|
val statusChanged = StickyEvent0()
|
|
var group: WifiP2pGroup? = null
|
|
set(value) {
|
|
field = value
|
|
groupChanged(value)
|
|
if (Build.VERSION.SDK_INT >= 28) value?.clientList?.let {
|
|
timeoutMonitor?.onClientsChanged(it.isEmpty())
|
|
}
|
|
}
|
|
val groupChanged = StickyEvent1 { group }
|
|
|
|
fun startWps(pin: String? = null) {
|
|
val channel = channel
|
|
if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show()
|
|
else if (active) p2pManager.startWps(channel, WpsInfo().apply {
|
|
setup = if (pin == null) WpsInfo.PBC else {
|
|
this.pin = pin
|
|
WpsInfo.KEYPAD
|
|
}
|
|
}, object : WifiP2pManager.ActionListener {
|
|
override fun onSuccess() = SmartSnackbar.make(
|
|
if (pin == null) R.string.repeater_wps_success_pbc else R.string.repeater_wps_success_keypad)
|
|
.shortToast().show()
|
|
override fun onFailure(reason: Int) = SmartSnackbar.make(
|
|
formatReason(R.string.repeater_wps_failure, reason)).show()
|
|
})
|
|
}
|
|
|
|
fun shutdown() {
|
|
if (active) removeGroup()
|
|
}
|
|
}
|
|
|
|
private val p2pManager get() = RepeaterService.p2pManager!!
|
|
private var channel: WifiP2pManager.Channel? = null
|
|
private val binder = Binder()
|
|
@RequiresApi(28)
|
|
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
|
private var receiverRegistered = false
|
|
private val receiver = broadcastReceiver { _, intent ->
|
|
when (intent.action) {
|
|
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION ->
|
|
if (intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, 0) ==
|
|
WifiP2pManager.WIFI_P2P_STATE_DISABLED) launch { cleanLocked() } // ignore P2P enabled
|
|
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> onP2pConnectionChanged(
|
|
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO)!!,
|
|
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP))
|
|
}
|
|
}
|
|
private val deviceListener = broadcastReceiver { _, intent ->
|
|
when (intent.action) {
|
|
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
|
|
val device = intent.getParcelableExtra<WifiP2pDevice>(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE)
|
|
val address = MacAddressCompat.fromString(device?.deviceAddress ?: return@broadcastReceiver)
|
|
if (Build.VERSION.SDK_INT < 29 || address != MacAddressCompat.ANY_ADDRESS) {
|
|
lastMac = device.deviceAddress
|
|
}
|
|
}
|
|
WifiP2pManagerHelper.WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION -> if (!safeMode) onPersistentGroupsChanged()
|
|
}
|
|
}
|
|
/**
|
|
* Writes and critical reads to routingManager should be protected with this context.
|
|
*/
|
|
private val dispatcher = newSingleThreadContext("RepeaterService")
|
|
override val coroutineContext = dispatcher + Job()
|
|
private var routingManager: RoutingManager? = null
|
|
private var persistNextGroup = false
|
|
|
|
var status = Status.IDLE
|
|
private set(value) {
|
|
if (field == value) return
|
|
field = value
|
|
binder.statusChanged()
|
|
}
|
|
|
|
private fun formatReason(@StringRes resId: Int, reason: Int) = getString(resId, when (reason) {
|
|
WifiP2pManager.ERROR -> getString(R.string.repeater_failure_reason_error)
|
|
WifiP2pManager.P2P_UNSUPPORTED -> getString(R.string.repeater_failure_reason_p2p_unsupported)
|
|
// we don't ever need to use discovering ever so busy must mean P2pStateMachine is in invalid state
|
|
WifiP2pManager.BUSY -> getString(R.string.repeater_p2p_unavailable)
|
|
// this should never be used
|
|
WifiP2pManager.NO_SERVICE_REQUESTS -> getString(R.string.repeater_failure_reason_no_service_requests)
|
|
WifiP2pManagerHelper.UNSUPPORTED -> getString(R.string.repeater_failure_reason_unsupported_operation)
|
|
else -> getString(R.string.failure_reason_unknown, reason)
|
|
})
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
onChannelDisconnected()
|
|
registerReceiver(deviceListener, intentFilter(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION,
|
|
WifiP2pManagerHelper.WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION))
|
|
app.pref.registerOnSharedPreferenceChangeListener(this)
|
|
}
|
|
|
|
override fun onBind(intent: Intent) = binder
|
|
|
|
private fun setOperatingChannel(oc: Int = operatingChannel) = try {
|
|
val channel = channel
|
|
if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show()
|
|
// we don't care about listening channel
|
|
else p2pManager.setWifiP2pChannels(channel, 0, oc, object : WifiP2pManager.ActionListener {
|
|
override fun onSuccess() { }
|
|
override fun onFailure(reason: Int) {
|
|
SmartSnackbar.make(formatReason(R.string.repeater_set_oc_failure, reason)).show()
|
|
}
|
|
})
|
|
} catch (e: InvocationTargetException) {
|
|
if (oc != 0) {
|
|
val message = getString(R.string.repeater_set_oc_failure, e.message)
|
|
SmartSnackbar.make(message).show()
|
|
Timber.w(RuntimeException("Failed to set operating channel $oc", e))
|
|
} else Timber.w(e)
|
|
}
|
|
|
|
override fun onChannelDisconnected() {
|
|
channel = null
|
|
if (status != Status.DESTROYED) try {
|
|
channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
|
|
if (!safeMode) setOperatingChannel()
|
|
} catch (e: RuntimeException) {
|
|
Timber.w(e)
|
|
launch(Dispatchers.Main) {
|
|
delay(1000)
|
|
onChannelDisconnected()
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
|
if (!safeMode && key == KEY_OPERATING_CHANNEL) setOperatingChannel()
|
|
}
|
|
|
|
@SuppressLint("NewApi") // networkId is available since Android 4.2
|
|
private fun onPersistentGroupsChanged() = launch {
|
|
val ownerAddress = lastMac?.let(MacAddressCompat.Companion::fromString) ?: withContext(Dispatchers.Default) {
|
|
try {
|
|
P2pSupplicantConfiguration().bssid
|
|
} catch (e: RuntimeException) {
|
|
Timber.d(e)
|
|
null
|
|
}
|
|
} ?: return@launch
|
|
val channel = channel ?: return@launch
|
|
try {
|
|
p2pManager.requestPersistentGroupInfo(channel) { groups ->
|
|
if (groups.isNotEmpty()) persistentSupported = true
|
|
val ownedGroups = groups.filter {
|
|
if (!it.isGroupOwner) return@filter false
|
|
val address = MacAddressCompat.fromString(it.owner.deviceAddress)
|
|
// WifiP2pServiceImpl only removes self address
|
|
Build.VERSION.SDK_INT >= 29 && address == MacAddressCompat.ANY_ADDRESS || address == ownerAddress
|
|
}
|
|
val main = ownedGroups.minBy { it.networkId }
|
|
// do not replace current group if it's better
|
|
if (binder.group?.passphrase == null) binder.group = main
|
|
if (main != null) ownedGroups.filter { it.networkId != main.networkId }.forEach {
|
|
p2pManager.deletePersistentGroup(channel, it.networkId, object : WifiP2pManager.ActionListener {
|
|
override fun onSuccess() = Timber.i("Removed redundant owned group: $it")
|
|
override fun onFailure(reason: Int) = SmartSnackbar.make(
|
|
formatReason(R.string.repeater_clean_pog_failure, reason)).show()
|
|
})
|
|
}
|
|
}
|
|
} catch (e: ReflectiveOperationException) {
|
|
Timber.w(e)
|
|
SmartSnackbar.make(e).show()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* startService Step 1
|
|
*/
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
if (status != Status.IDLE) return START_NOT_STICKY
|
|
val channel = channel ?: return START_NOT_STICKY.also { stopSelf() }
|
|
status = Status.STARTING
|
|
// bump self to foreground location service to use foreground location permission later
|
|
if (Build.VERSION.SDK_INT >= 29 ||
|
|
// or show invisible foreground notification on television to avoid being killed
|
|
Build.VERSION.SDK_INT >= 26 && app.uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
|
|
showNotification()
|
|
}
|
|
launch {
|
|
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
|
|
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
|
|
receiverRegistered = true
|
|
try {
|
|
p2pManager.requestGroupInfo(channel) {
|
|
when {
|
|
it == null -> doStart()
|
|
it.isGroupOwner -> launch { if (routingManager == null) doStartLocked(it) }
|
|
else -> {
|
|
Timber.i("Removing old group ($it)")
|
|
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
|
override fun onSuccess() = doStart()
|
|
override fun onFailure(reason: Int) =
|
|
startFailure(formatReason(R.string.repeater_remove_old_group_failure, reason))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} catch (e: SecurityException) {
|
|
Timber.w(e)
|
|
startFailure(e.readableMessage)
|
|
}
|
|
}
|
|
return START_NOT_STICKY
|
|
}
|
|
/**
|
|
* startService Step 2 (if a group isn't already available)
|
|
*/
|
|
private fun doStart() {
|
|
val listener = object : WifiP2pManager.ActionListener {
|
|
override fun onFailure(reason: Int) {
|
|
startFailure(formatReason(R.string.repeater_create_group_failure, reason),
|
|
showWifiEnable = reason == WifiP2pManager.BUSY)
|
|
}
|
|
override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3
|
|
}
|
|
val channel = channel ?: return listener.onFailure(WifiP2pManager.BUSY)
|
|
val networkName = networkName
|
|
val passphrase = passphrase
|
|
try {
|
|
if (!safeMode || networkName == null || passphrase == null) {
|
|
persistNextGroup = true
|
|
p2pManager.createGroup(channel, listener)
|
|
} else @TargetApi(29) {
|
|
p2pManager.createGroup(channel, WifiP2pConfig.Builder().apply {
|
|
setNetworkName(PLACEHOLDER_NETWORK_NAME)
|
|
setPassphrase(passphrase)
|
|
operatingChannel.let { oc ->
|
|
if (oc == 0) setGroupOperatingBand(operatingBand)
|
|
else setGroupOperatingFrequency(SoftApConfigurationCompat.channelToFrequency(oc))
|
|
}
|
|
setDeviceAddress(deviceAddress?.toPlatform())
|
|
}.build().run {
|
|
useParcel { p ->
|
|
p.writeParcelable(this, 0)
|
|
val end = p.dataPosition()
|
|
p.setDataPosition(0)
|
|
val creator = p.readString()
|
|
val deviceAddress = p.readString()
|
|
val wps = p.readParcelable<WpsInfo>(javaClass.classLoader)
|
|
val long = p.readLong()
|
|
check(p.readString() == PLACEHOLDER_NETWORK_NAME)
|
|
check(p.readString() == passphrase)
|
|
val extrasLength = end - p.dataPosition()
|
|
check(extrasLength and 3 == 0) // parcel should be padded
|
|
if (extrasLength != 4) Timber.w(Exception("Unexpected extrasLength $extrasLength"))
|
|
val extras = (0 until extrasLength / 4).map { p.readInt() }
|
|
p.setDataPosition(0)
|
|
p.writeString(creator)
|
|
p.writeString(deviceAddress)
|
|
p.writeParcelable(wps, 0)
|
|
p.writeLong(long)
|
|
p.writeString(networkName)
|
|
p.writeString(passphrase)
|
|
extras.forEach(p::writeInt)
|
|
p.setDataPosition(0)
|
|
p.readParcelable<WifiP2pConfig>(javaClass.classLoader)
|
|
}
|
|
}, listener)
|
|
}
|
|
} catch (e: SecurityException) {
|
|
Timber.w(e)
|
|
startFailure(e.readableMessage)
|
|
}
|
|
}
|
|
/**
|
|
* Used during step 2, also called when connection changed
|
|
*/
|
|
private fun onP2pConnectionChanged(info: WifiP2pInfo, group: WifiP2pGroup?) = launch {
|
|
Timber.d("P2P connection changed: $info\n$group")
|
|
when {
|
|
!info.groupFormed || !info.isGroupOwner || group?.isGroupOwner != true -> {
|
|
if (routingManager != null) cleanLocked()
|
|
// P2P shutdown, else other groups changing before start, ignore
|
|
}
|
|
routingManager != null -> {
|
|
binder.group = group
|
|
showNotification(group)
|
|
}
|
|
else -> doStartLocked(group)
|
|
}
|
|
}
|
|
/**
|
|
* startService Step 3
|
|
*/
|
|
private fun doStartLocked(group: WifiP2pGroup) {
|
|
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor = TetherTimeoutMonitor(this, binder::shutdown)
|
|
binder.group = group
|
|
if (persistNextGroup) {
|
|
networkName = group.networkName
|
|
passphrase = group.passphrase
|
|
persistNextGroup = false
|
|
}
|
|
check(routingManager == null)
|
|
routingManager = object : RoutingManager.LocalOnly(this@RepeaterService, group.`interface`!!) {
|
|
override fun ifaceHandler(iface: NetworkInterface) {
|
|
iface.hardwareAddress?.let { lastMac = MacAddressCompat.bytesToString(it) }
|
|
}
|
|
}.apply { start() }
|
|
status = Status.ACTIVE
|
|
showNotification(group)
|
|
}
|
|
private fun startFailure(msg: CharSequence, group: WifiP2pGroup? = null, showWifiEnable: Boolean = false) {
|
|
SmartSnackbar.make(msg).apply {
|
|
if (showWifiEnable) action(R.string.repeater_p2p_unavailable_enable) {
|
|
if (Build.VERSION.SDK_INT < 29) @Suppress("DEPRECATION") {
|
|
app.wifi.isWifiEnabled = true
|
|
} else it.context.startActivity(Intent(Settings.Panel.ACTION_WIFI))
|
|
}
|
|
}.show()
|
|
showNotification()
|
|
if (group != null) removeGroup() else launch { cleanLocked() }
|
|
}
|
|
|
|
private fun showNotification(group: WifiP2pGroup? = null) = ServiceNotification.startForeground(this,
|
|
if (group == null) emptyMap() else mapOf(Pair(group.`interface`, group.clientList?.size ?: 0)))
|
|
|
|
private fun removeGroup() {
|
|
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
|
override fun onSuccess() {
|
|
launch { cleanLocked() }
|
|
}
|
|
override fun onFailure(reason: Int) {
|
|
if (reason != WifiP2pManager.BUSY) {
|
|
SmartSnackbar.make(formatReason(R.string.repeater_remove_group_failure, reason)).show()
|
|
} // else assuming it's already gone
|
|
launch { cleanLocked() }
|
|
}
|
|
})
|
|
}
|
|
private fun cleanLocked() {
|
|
if (receiverRegistered) {
|
|
ensureReceiverUnregistered(receiver)
|
|
receiverRegistered = false
|
|
}
|
|
if (Build.VERSION.SDK_INT >= 28) {
|
|
timeoutMonitor?.close()
|
|
timeoutMonitor = null
|
|
}
|
|
routingManager?.stop()
|
|
routingManager = null
|
|
status = Status.IDLE
|
|
ServiceNotification.stopForeground(this)
|
|
stopSelf()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
if (status != Status.IDLE) binder.shutdown()
|
|
launch { // force clean to prevent leakage
|
|
cleanLocked()
|
|
cancel()
|
|
dispatcher.close()
|
|
}
|
|
app.pref.unregisterOnSharedPreferenceChangeListener(this)
|
|
unregisterReceiver(deviceListener)
|
|
status = Status.DESTROYED
|
|
if (Build.VERSION.SDK_INT >= 27) channel?.close()
|
|
super.onDestroy()
|
|
}
|
|
}
|