From 96ee5ea45ef24c7d18c3bc1a39ab78e596b5d572 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sat, 30 May 2026 23:05:08 +0300 Subject: [PATCH] feat: add network connectivity observer --- app/src/main/AndroidManifest.xml | 1 + .../fast/common/di/ApplicationModule.kt | 3 + .../meloda/fast/presentation/MainActivity.kt | 1 + .../fast/presentation/NetworkObserver.kt | 274 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 app/src/main/kotlin/dev/meloda/fast/presentation/NetworkObserver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4c91ec1..62da451b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt index 296696b4..54bccc4e 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt @@ -22,6 +22,7 @@ import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.logger.loggerModule import dev.meloda.fast.messageshistory.di.messagesHistoryModule import dev.meloda.fast.photoviewer.di.photoViewModule +import dev.meloda.fast.presentation.NetworkObserver import dev.meloda.fast.profile.di.profileModule import dev.meloda.fast.provider.ApiLanguageProvider import dev.meloda.fast.service.longpolling.di.longPollModule @@ -72,4 +73,6 @@ val applicationModule = module { singleOf(::LongPollControllerImpl) bind LongPollController::class singleOf(::ResourceProviderImpl) bind ResourceProvider::class + + singleOf(::NetworkObserver) } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt index 68f76bef..3e074a16 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt @@ -184,6 +184,7 @@ class MainActivity : AppCompatActivity() { super.onDestroy() stopServices() get().onDestroy() + get().onDestroy() } companion object { diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/NetworkObserver.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/NetworkObserver.kt new file mode 100644 index 00000000..75c7f9c9 --- /dev/null +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/NetworkObserver.kt @@ -0,0 +1,274 @@ +package dev.meloda.fast.presentation + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dev.meloda.fast.logger.FastLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +class NetworkObserver( + context: Context, + private val logger: FastLogger +) { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val networkStatus = MutableStateFlow(NetworkStatus.UNAVAILABLE) + val networkStatusFlow = networkStatus.asStateFlow() + + private val networkState = MutableStateFlow(NetworkState.DISCONNECTED) + val networkStateFlow = networkState.asStateFlow() + + private val networks = ConcurrentHashMap() + + private var clearCallbacks: (() -> Unit)? = null + + init { + startListener() + } + + private fun syncNetworkState() { + val state = if (networks.values.any { it.isInternetAvailable() }) { + NetworkState.CONNECTED + } else { + NetworkState.DISCONNECTED + } + + networkState.value = state + networkStatus.value = when (state) { + NetworkState.CONNECTED -> NetworkStatus.AVAILABLE + NetworkState.DISCONNECTED -> NetworkStatus.UNAVAILABLE + } + + log("STATE: $state") + } + + private fun startListener() { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + log("onAvailable(): network: $network") + + networks[network] = mapNetworkModel( + network = network, + capabilities = connectivityManager.getNetworkCapabilities(network), + properties = connectivityManager.getLinkProperties(network), + status = NetworkStatus.AVAILABLE + ) + + syncNetworkState() + } + + override fun onUnavailable() { + log("onUnavailable()") + + networks.clear() + syncNetworkState() + } + + override fun onLost(network: Network) { + log("onLost() network: $network") + + networks.remove(network) + syncNetworkState() + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + log("onCapabilitiesChanged(): network: $network; caps: $networkCapabilities") + + val current = networks[network] + networks[network] = mapNetworkModel( + network = network, + capabilities = networkCapabilities, + from = current, + status = current?.status ?: NetworkStatus.AVAILABLE + ) + + syncNetworkState() + } + + override fun onBlockedStatusChanged( + network: Network, + blocked: Boolean + ) { + log("onBlockedStatusChanged(): network: $network; blocked: $blocked") + + networks[network] = mapNetworkModel( + network = network, + from = networks[network], + status = if (blocked) NetworkStatus.BLOCKED else NetworkStatus.UNBLOCKED + ) + + syncNetworkState() + } + + override fun onLinkPropertiesChanged( + network: Network, + linkProperties: LinkProperties + ) { + log("onLinkPropertiesChanged(): network: $network; props: $linkProperties") + + val current = networks[network] + networks[network] = mapNetworkModel( + network = network, + properties = linkProperties, + from = current, + status = current?.status ?: NetworkStatus.AVAILABLE + ) + + syncNetworkState() + } + + override fun onLosing(network: Network, maxMsToLive: Int) { + log("onLosing(): network: $network; maxMsToLive: $maxMsToLive") + + val current = networks[network] + networks[network] = mapNetworkModel( + network = network, + maxMsToLive = maxMsToLive.toLong(), + from = current, + status = current?.status ?: NetworkStatus.AVAILABLE + ) + + syncNetworkState() + } + + override fun onReserved(networkCapabilities: NetworkCapabilities) { + log("onReserved(): caps: $networkCapabilities") + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + clearCallbacks = { connectivityManager.unregisterNetworkCallback(callback) } + + refreshActiveNetwork() + } + + private fun refreshActiveNetwork() { + val network = connectivityManager.activeNetwork + if (network == null) { + networks.clear() + syncNetworkState() + return + } + + val capabilities = connectivityManager.getNetworkCapabilities(network) + if (capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) { + networks[network] = mapNetworkModel( + network = network, + capabilities = capabilities, + properties = connectivityManager.getLinkProperties(network), + status = NetworkStatus.AVAILABLE + ) + } + + syncNetworkState() + } + + private fun log(text: String) { + logger.debug(this::class, text) + } + + private fun mapNetworkModel( + network: Network, + capabilities: NetworkCapabilities? = null, + properties: LinkProperties? = null, + status: NetworkStatus? = null, + maxMsToLive: Long? = null, + from: NetworkModel? = null + ): NetworkModel { + val caps = capabilities + ?: from?.networkCapabilities + ?: connectivityManager.getNetworkCapabilities(network) + + val networkType = when { + caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> NetworkType.CELLULAR + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> NetworkType.WIFI + else -> from?.type ?: NetworkType.UNKNOWN + } + + val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ?: from?.hasInternet + ?: false + + val signalStrength = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + caps?.signalStrength + } else { + null + } ?: from?.signalStrength ?: Int.MAX_VALUE + + return NetworkModel( + id = network.hashCode(), + type = networkType, + original = network, + hasInternet = hasInternet, + signalStrength = signalStrength, + status = status ?: from?.status ?: NetworkStatus.UNAVAILABLE, + maxMsToLive = maxMsToLive ?: from?.maxMsToLive, + networkCapabilities = caps, + linkProperties = properties + ?: from?.linkProperties + ?: connectivityManager.getLinkProperties(network) + ) + } + + fun onDestroy() { + clearCallbacks?.let { unregisterCallback -> + runCatching { unregisterCallback() } + .onFailure { throwable -> + logger.error( + this::class.java, + "Failed to unregister network callback", + throwable + ) + } + } + clearCallbacks = null + networks.clear() + syncNetworkState() + } +} + +enum class NetworkType { + CELLULAR, WIFI, UNKNOWN +} + +data class NetworkModel( + val id: Int, + val type: NetworkType, + val original: Network, + val hasInternet: Boolean, + val signalStrength: Int, + val status: NetworkStatus, + val maxMsToLive: Long?, + val networkCapabilities: NetworkCapabilities?, + val linkProperties: LinkProperties? +) { + fun isStatusOk(): Boolean = status.isOk() + + fun isInternetAvailable(): Boolean = hasInternet && isStatusOk() +} + +enum class NetworkStatus { + AVAILABLE, UNAVAILABLE, LOST, BLOCKED, UNBLOCKED; + + fun isOk(): Boolean = when (this) { + AVAILABLE, UNBLOCKED -> true + UNAVAILABLE, LOST, BLOCKED -> false + } +} + +enum class NetworkState { CONNECTED, DISCONNECTED }