Compare commits

..

4 Commits

Author SHA1 Message Date
Zane Schepke 9b3283a2b1 chore: release 4.1.4 2025-11-04 20:20:41 -05:00
Zane Schepke 78def29980 fix: keep network monitor for full app lifecyle 2025-11-04 20:16:23 -05:00
Zane Schepke e83bbdf23a fix: tunnel service bind race 2025-11-04 19:59:30 -05:00
Zane Schepke 4beeb4e01e fix: network monitoring bug 2025-11-04 17:48:40 -05:00
8 changed files with 361 additions and 286 deletions
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
class ServiceManager
@@ -137,17 +138,25 @@ constructor(
suspend fun startTunnelService(appMode: AppMode) =
tunnelMutex.withLock {
if (_tunnelService.value != null) return@withLock
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
if (_tunnelService.value != null) {
Timber.d("Service already exists, waiting for disconnect")
withTimeoutOrNull(2000L) { _tunnelService.first { it == null } }
?: Timber.w("Timeout waiting for existing service to disconnect")
}
if (_tunnelService.value == null) {
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
} else {
Timber.e("Service still not null after timeout")
}
}
suspend fun stopTunnelService() =
@@ -157,7 +166,7 @@ constructor(
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to stop Tunnel Service")
Timber.e(e, "Failed to unbind Tunnel Service")
}
}
}
@@ -61,6 +61,9 @@ class AutoTunnelService : LifecycleService() {
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
private var autoTunnelJob: Job? = null
private var permissionsJob: Job? = null
class LocalBinder(service: AutoTunnelService) : Binder() {
private val serviceRef = WeakReference(service)
@@ -89,8 +92,10 @@ class AutoTunnelService : LifecycleService() {
fun start() {
launchWatcherNotification()
startAutoTunnelStateJob()
startLocationPermissionsNotificationJob()
autoTunnelJob?.cancel()
autoTunnelJob = startAutoTunnelStateJob()
permissionsJob?.cancel()
permissionsJob = startLocationPermissionsNotificationJob()
}
fun stop() {
@@ -99,7 +104,6 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.handleAutoTunnelServiceDestroy()
networkMonitor.destroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -130,7 +134,7 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun startAutoTunnelStateJob() =
private fun startAutoTunnelStateJob(): Job =
lifecycleScope.launch(ioDispatcher) {
val networkFlow =
debouncedConnectivityStateFlow
@@ -59,8 +59,8 @@ data class AutoTunnelState(
return DoNothing
}
private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular
private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet
private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet
private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular
private val wifiActive: Boolean = networkState.activeNetwork is ActiveNetwork.Wifi
private fun preferredMobileDataTunnel(): TunnelConfig? {
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "4.1.3"
const val VERSION_CODE = 40103
const val VERSION_NAME = "4.1.4"
const val VERSION_CODE = 40104
const val TARGET_SDK = 36
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -0,0 +1,3 @@
What's new:
- Auto tunnel network detection bugfix
- Tunnel notification sometimes don't start bugfix
@@ -1,25 +1,20 @@
package com.zaneschepke.networkmonitor
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.*
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import com.zaneschepke.networkmonitor.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
@@ -68,119 +63,103 @@ class AndroidNetworkMonitor(
private val locationManager =
appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val activeWifiNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val permissionsChangedFlow = MutableStateFlow(false)
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: BroadcastReceiver? = null
private var defaultNetworkCallback: ConnectivityManager.NetworkCallback? = null
private var wifiInterfaceCallback: ConnectivityManager.NetworkCallback? = null
private var cellularInterfaceCallback: ConnectivityManager.NetworkCallback? = null
private val isAirplaneModeOn: Boolean
get() =
android.provider.Settings.Global.getInt(
appContext.contentResolver,
android.provider.Settings.Global.AIRPLANE_MODE_ON,
0,
) != 0
private var wifiCallback: ConnectivityManager.NetworkCallback? = null
private var cellularCallback: ConnectivityManager.NetworkCallback? = null
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
@OptIn(ExperimentalCoroutinesApi::class)
private val defaultNetworkFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) {
detectionMethod,
changed ->
Pair(detectionMethod, changed)
}
.flatMapLatest { (detectionMethod, _) ->
createDefaultNetworkCallbackFlow(detectionMethod)
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { detectionMethod ->
callbackFlow {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT
) {
defaultNetworkCallback =
object :
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network")
}
private fun isAndroidTv(): Boolean =
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
private fun hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && isAndroidTv())
) {
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
private fun createDefaultNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Network onAvailable: network=$network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Network onLost: network=$network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit =
{ network, networkCapabilities ->
val isValidated =
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasInternet =
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
Timber.d("onCapabilitiesChanged: network=$network, validated: $isValidated")
if (isValidated && hasInternet) {
val event =
when {
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
activeWifiNetworks[network.toString()] =
Pair(network, networkCapabilities)
TransportEvent.CapabilitiesChanged(
network,
networkCapabilities,
detectionMethod,
)
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) -> {
activeWifiNetworks.clear()
TransportEvent.CapabilitiesChanged(network, networkCapabilities)
} else {
defaultNetworkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network")
}
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> {
activeWifiNetworks.clear()
TransportEvent.CapabilitiesChanged(network, networkCapabilities)
}
else -> TransportEvent.Unknown
}
trySend(event)
} else {
activeWifiNetworks.remove(network.toString())
trySend(TransportEvent.Lost(network))
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
)
)
awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
}
}
}
val callback: ConnectivityManager.NetworkCallback =
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { detectionMethod -> createWifiNetworkCallbackFlow(detectionMethod) }
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network -> Timber.d("WiFi onAvailable: $network") }
val onLost: (Network) -> Unit = { network ->
Timber.d("WiFi onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
wifiCallback =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) = onAvailable(network)
@@ -189,8 +168,8 @@ class AndroidNetworkMonitor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) = onCapabilitiesChanged(network, networkCapabilities)
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
} else {
object : ConnectivityManager.NetworkCallback() {
@@ -200,94 +179,92 @@ class AndroidNetworkMonitor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) = onCapabilitiesChanged(network, networkCapabilities)
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
}
defaultNetworkCallback = callback
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
permissions =
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
hasRequiredLocationPermissions(),
)
)
)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!) }
.onFailure { Timber.e(it, "Error unregistering default network callback") }
}
}
private val wifiInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network")
trySend(true)
}
override fun onLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network")
trySend(false)
}
}
wifiInterfaceCallback = localCallback
val request =
NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
connectivityManager?.registerNetworkCallback(request, wifiInterfaceCallback!!)
@Suppress("DEPRECATION") val isWifiInitiallyOn = wifiManager?.isWifiEnabled == true
trySend(isWifiInitiallyOn)
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiInterfaceCallback!!) }
.onFailure { Timber.e(it, "Error unregistering Wi-Fi interface callback") }
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
}
}
private val cellularInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Cellular onAvailable: network=$network")
trySend(true)
}
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Cellular onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
override fun onLost(network: Network) {
Timber.d("Cellular onLost: network=$network")
trySend(false)
}
cellularCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
cellularInterfaceCallback = localCallback
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
connectivityManager?.registerNetworkCallback(request, cellularInterfaceCallback!!)
// initial state
val initialCellularNetwork = connectivityManager?.activeNetwork
val initialCapabilities =
connectivityManager?.getNetworkCapabilities(initialCellularNetwork)
val isCellularInitiallyOn =
initialCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
trySend(isCellularInitiallyOn)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching {
connectivityManager?.unregisterNetworkCallback(cellularInterfaceCallback!!)
}
.onFailure { Timber.e(it, "Error unregistering cellular interface callback") }
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
}
}
private val ethernetFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Ethernet onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Ethernet onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Ethernet onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
ethernetCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback!!) }
.onFailure { Timber.e(it, "Error unregistering ethernet network callback") }
}
}
@@ -297,7 +274,7 @@ class AndroidNetworkMonitor(
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
Timber.d("Received airplane mode changed broadcast")
trySend(isAirplaneModeOn)
trySend(appContext.isAirplaneModeOn())
}
}
}
@@ -306,7 +283,7 @@ class AndroidNetworkMonitor(
appContext.registerReceiver(receiver, filter)
// initial state
trySend(isAirplaneModeOn)
trySend(appContext.isAirplaneModeOn())
awaitClose {
runCatching { appContext.unregisterReceiver(receiver) }
@@ -314,6 +291,53 @@ class AndroidNetworkMonitor(
}
}
private val wifiStateFlow: Flow<NetworkCapabilities?> =
wifiFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val cellularStateFlow: Flow<NetworkCapabilities?> =
cellularFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val ethernetStateFlow: Flow<NetworkCapabilities?> =
ethernetFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private suspend fun getSsidByDetectionMethod(
detectionMethod: WifiDetectionMethod?,
networkCapabilities: NetworkCapabilities?,
@@ -345,90 +369,100 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
defaultNetworkFlow.scan(
ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected,
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
) { previous, event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> {
when {
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
val ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod
?: WifiDetectionMethod.DEFAULT,
event.networkCapabilities,
)
private data class NetworkData(
val defaultEvent: TransportEvent,
val wifiCaps: NetworkCapabilities?,
val cellularCaps: NetworkCapabilities?,
val ethernetCaps: NetworkCapabilities?,
)
previous.copy(
activeNetwork =
ActiveNetwork.Wifi(
ssid = ssid,
securityType = wifiManager?.getCurrentSecurityType(),
)
)
private val networkFlows: Flow<NetworkData> =
combine(defaultNetworkFlow, wifiStateFlow, cellularStateFlow, ethernetStateFlow) {
defaultEvent,
wifiCaps,
cellularCaps,
ethernetCaps ->
NetworkData(defaultEvent, wifiCaps, cellularCaps, ethernetCaps)
}
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) {
networkData,
isAirplaneOn,
detectionMethod ->
val defaultEvent = networkData.defaultEvent
val wifiCaps = networkData.wifiCaps
val cellularCaps = networkData.cellularCaps
val ethernetCaps = networkData.ethernetCaps
val permissions =
when (defaultEvent) {
is TransportEvent.Permissions -> defaultEvent.permissions
else ->
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
}
val defaultCaps =
when (defaultEvent) {
is TransportEvent.CapabilitiesChanged -> defaultEvent.networkCapabilities
else ->
connectivityManager?.getNetworkCapabilities(
connectivityManager.activeNetwork
)
}
?: return@combine ConnectivityState(
ActiveNetwork.Disconnected,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
val isValidated =
defaultCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasInternet =
defaultCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
if (!isValidated || !hasInternet) {
return@combine ConnectivityState(
ActiveNetwork.Disconnected,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
} else {
val activeNetwork: ActiveNetwork =
if (defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
// Ignore VPN, determine underlying
when {
wifiCaps != null -> {
val ssid = getSsidByDetectionMethod(detectionMethod, wifiCaps)
ActiveNetwork.Wifi(ssid, wifiManager?.getCurrentSecurityType())
}
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) -> {
activeWifiNetworks.clear()
previous.copy(activeNetwork = ActiveNetwork.Cellular)
ethernetCaps != null -> ActiveNetwork.Ethernet
cellularCaps != null && !isAirplaneOn -> ActiveNetwork.Cellular
else -> ActiveNetwork.Disconnected
}
} else {
when {
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, defaultCaps)
ActiveNetwork.Wifi(ssid, wifiManager?.getCurrentSecurityType())
}
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> {
activeWifiNetworks.clear()
previous.copy(activeNetwork = ActiveNetwork.Ethernet)
}
else -> previous
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
!isAirplaneOn -> ActiveNetwork.Cellular
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
ActiveNetwork.Ethernet
else -> ActiveNetwork.Disconnected
}
}
is TransportEvent.Lost ->
previous.copy(activeNetwork = ActiveNetwork.Disconnected)
is TransportEvent.Permissions -> {
previous.copy(
locationPermissionsGranted =
event.permissions.locationPermissionGranted,
locationServicesEnabled = event.permissions.locationServicesEnabled,
)
}
is TransportEvent.Available -> previous
is TransportEvent.Unknown -> previous
}
},
wifiInterfaceFlow,
airplaneModeFlow,
cellularInterfaceFlow,
) { defaultState, isWifiInterfaceOn, isAirplaneModeOn, isCellularInterfaceOn ->
val activeNetwork =
when {
// Wi-Fi interface disabled, force disconnected
!isWifiInterfaceOn && defaultState.activeNetwork is ActiveNetwork.Wifi ->
ActiveNetwork.Disconnected
// Cellular active when airplane mode on
isAirplaneModeOn && defaultState.activeNetwork is ActiveNetwork.Cellular ->
ActiveNetwork.Disconnected
// Cellular active when cellular interface disabled
!isCellularInterfaceOn &&
defaultState.activeNetwork is ActiveNetwork.Cellular ->
ActiveNetwork.Disconnected
else -> defaultState.activeNetwork
}
ConnectivityState(
activeNetwork = activeNetwork,
locationPermissionsGranted = defaultState.locationPermissionsGranted,
locationServicesEnabled = defaultState.locationServicesEnabled,
ConnectivityState(
activeNetwork,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
.also { Timber.i("Connectivity Status: $it") }
}
}
.distinctUntilChanged()
.shareIn(applicationScope, SharingStarted.Eagerly, replay = 1)
@@ -452,7 +486,7 @@ class AndroidNetworkMonitor(
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == actionPermissionCheck) {
val isGranted = hasRequiredLocationPermissions()
val isGranted = appContext.hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
if (
connectivityStateFlow.replayCache
@@ -462,7 +496,6 @@ class AndroidNetworkMonitor(
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
@@ -487,7 +520,6 @@ class AndroidNetworkMonitor(
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
@@ -508,10 +540,9 @@ class AndroidNetworkMonitor(
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiInterfaceCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularInterfaceCallback?.let {
connectivityManager?.unregisterNetworkCallback(it)
}
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
}
.onFailure { Timber.e(it, "Error during cleanup") }
Timber.d("NetworkMonitor cleaned up")
@@ -27,17 +27,14 @@ data class ConnectivityState(
}
}
data class Permissions(val locationServicesEnabled: Boolean, val locationPermissionGranted: Boolean)
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
data object Ethernet : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType?) : ActiveNetwork()
data object Cellular : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType? = null) : ActiveNetwork()
data object Ethernet : ActiveNetwork()
}
data class Permissions(
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
)
@@ -1,10 +1,15 @@
package com.zaneschepke.networkmonitor.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.Companion.ANDROID_UNKNOWN_SSID
import kotlinx.coroutines.Dispatchers
@@ -62,3 +67,29 @@ fun LocationManager.isLocationServicesEnabled(): Boolean {
false
}
}
fun Context.hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
fun Context.isAirplaneModeOn(): Boolean {
return Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
}