From afbd1c089b73ba398d166942b31cd6fd29e723d3 Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:10:02 +0200 Subject: [PATCH 1/8] feat: incorporar amplias comprobaciones de conectividad (tier 1 app domains, tier 2 fallback DNS) --- .../app/src/main/AndroidManifest.xml | 1 + .../ui/list/PokemonListActivity.kt | 247 ++- .../ui/list/PokemonListViewModel.kt | 92 +- .../util/AppConnectivityManager.kt | 44 + .../util/ConnectivityAndInternetAccess.kt | 1634 +++++++++++++++++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 1999 insertions(+), 21 deletions(-) create mode 100644 PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt create mode 100644 PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt diff --git a/PokemonJetpackCompose/app/src/main/AndroidManifest.xml b/PokemonJetpackCompose/app/src/main/AndroidManifest.xml index 13f621a..d13f366 100644 --- a/PokemonJetpackCompose/app/src/main/AndroidManifest.xml +++ b/PokemonJetpackCompose/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + - LazyColumn( - modifier = Modifier.padding(padding) + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) ) { - items(viewModel.pokemonList) { pokemon -> - PokemonCell(pokemon) + // Connectivity Status Banner + ConnectivityBanner( + viewModel = viewModel, + onDiagnoseClick = { + viewModel.performConnectivityCheck(context) + showDiagnosticDetails = true + }, + onToggleDetails = { + showDiagnosticDetails = !showDiagnosticDetails + } + ) + + // Diagnostic details dialog/expandable view + AnimatedVisibility(visible = showDiagnosticDetails && viewModel.diagnosticSummary.isNotEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = 4.dp, + shape = RoundedCornerShape(8.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Detalles del Diagnóstico de Red:", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = viewModel.diagnosticSummary, + fontSize = 13.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Orden de comprobación: 1º Endpoints App (pokeapi.co) -> 2º Fallback Extremo (DNS publicos / dominios por defecto).", + fontSize = 11.sp, + color = Color.Gray + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (viewModel.isLoadingData) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } else if (viewModel.errorLoadingData && viewModel.pokemonList.isEmpty()) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color.Red, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("Error al cargar la lista de Pokémon.") + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.loadData() }) { + Text("Reintentar") + } + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(viewModel.pokemonList) { pokemon -> + PokemonCell(pokemon) + } + } + } } } } +} +@Composable +fun ConnectivityBanner( + viewModel: PokemonListViewModel, + onDiagnoseClick: () -> Unit, + onToggleDetails: () -> Unit +) { + val state = viewModel.networkState + val isAppReachable = viewModel.isAppBackendReachable + val isFallbackReachable = viewModel.isFallbackInternetReachable + val isChecking = viewModel.isCheckingConnectivity + + val isConnected = state?.connected == true + + if (isConnected && isAppReachable != false) { + // Connected & App Backend is healthy or checking + if (isChecking) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFE3F2FD)) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Comprobando conectividad con pokeapi.co...", + fontSize = 12.sp, + color = Color(0xFF0D47A1) + ) + } + } + return + } + + // Determine Banner Color & Message + val (backgroundColor, textColor, icon, message) = when { + !isConnected -> Quadruple( + Color(0xFFFFEBEE), + Color(0xFFC62828), + Icons.Default.Warning, + "Sin conexión de red en el dispositivo" + ) + isAppReachable == false && isFallbackReachable == true -> Quadruple( + Color(0xFFFFF3E0), + Color(0xFFE65100), + Icons.Default.Warning, + "Internet disponible, pero PokéAPI (pokeapi.co) no responde" + ) + else -> Quadruple( + Color(0xFFFFEBEE), + Color(0xFFC62828), + Icons.Default.Warning, + "Fallo de conectividad detectado" + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clickable { onToggleDetails() }, + backgroundColor = backgroundColor, + elevation = 2.dp, + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = message, + color = textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp + ) + Text( + text = "Toca para ver detalles o diagnosticar", + color = textColor.copy(alpha = 0.7f), + fontSize = 11.sp + ) + } + } + + OutlinedButton( + onClick = onDiagnoseClick, + modifier = Modifier.padding(start = 8.dp) + ) { + Text(if (isChecking) "Probando..." else "Diagnosticar", fontSize = 11.sp) + } + } + } } +private data class Quadruple(val first: A, val second: B, val third: C, val fourth: D) @Preview(showSystemUi = true) @Composable fun PokemonListDefaultPreview() { PokemonJetpackComposeTheme { - PokemonList() + PokemonList(viewModel = PokemonListViewModel()) } } \ No newline at end of file diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt index 422844b..7a6b122 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt @@ -1,31 +1,113 @@ package com.mouredev.pokemonjetpackcompose.ui.list -import androidx.compose.runtime.mutableStateOf +import android.content.Context import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.mouredev.pokemonjetpackcompose.api.PokemonAPI import com.mouredev.pokemonjetpackcompose.model.Pokemon +import com.mouredev.pokemonjetpackcompose.util.AppConnectivityManager +import com.mouredev.pokemonjetpackcompose.util.ConnectivityAndInternetAccess /** * Created by MoureDev by Brais Moure on 28/10/22. - * www.mouredev.com + * Modernized with comprehensive connectivity observation & tiered active checks. */ -class PokemonListViewModel: ViewModel() { +class PokemonListViewModel : ViewModel() { var pokemonList: List by mutableStateOf(listOf()) + var isLoadingData: Boolean by mutableStateOf(false) + var errorLoadingData: Boolean by mutableStateOf(false) + + // Passive network state + var networkState: ConnectivityAndInternetAccess.NetworkState? by mutableStateOf(null) + + // Active reachability states + var isAppBackendReachable: Boolean? by mutableStateOf(null) + var isFallbackInternetReachable: Boolean? by mutableStateOf(null) + var isCheckingConnectivity: Boolean by mutableStateOf(false) + var diagnosticSummary: String by mutableStateOf("") + + private var networkObserver: ConnectivityAndInternetAccess.NetworkObserver? = null + private var activeRequest: ConnectivityAndInternetAccess.Request? = null init { loadData() } - private fun loadData() { + fun startObservingNetwork(context: Context) { + if (networkObserver != null) return + + networkObserver = ConnectivityAndInternetAccess.observeNetwork(context) { state -> + networkState = state + if (state.connected) { + // Perform active check with app endpoints first when connected + performConnectivityCheck(context) + } else { + isAppBackendReachable = false + isFallbackInternetReachable = false + diagnosticSummary = "Sin conexión de red." + } + } + } + + fun stopObservingNetwork() { + networkObserver?.close() + networkObserver = null + activeRequest?.cancel() + activeRequest = null + } + + fun performConnectivityCheck(context: Context, runExtremeFallbackAlways: Boolean = false) { + isCheckingConnectivity = true + activeRequest?.cancel() + + // Tier 1: Check primary app endpoints first + activeRequest = AppConnectivityManager.checkAppEndpointsAsync(context) { appResult -> + if (appResult.reachable) { + isAppBackendReachable = true + isFallbackInternetReachable = true + isCheckingConnectivity = false + diagnosticSummary = "Conectado al servidor de la app via: ${appResult.reachedHost} (${appResult.elapsedMilliseconds} ms)" + if (pokemonList.isEmpty() || errorLoadingData) { + loadData() + } + } else { + isAppBackendReachable = false + diagnosticSummary = "Endpoint de la app inalcanzable. Iniciando diagnóstico extremo (DNS público/dominios por defecto)..." + + // Tier 2 (Extreme cases only): Fallback to public DNS & default domains + activeRequest = AppConnectivityManager.checkExtremeFallbackAsync(context) { fallbackResult -> + isCheckingConnectivity = false + if (fallbackResult.reachable) { + isFallbackInternetReachable = true + diagnosticSummary = "Internet general disponible via ${fallbackResult.reachedHost}, pero los servidores de Pokémon están caídos o inaccesibles." + } else { + isFallbackInternetReachable = false + diagnosticSummary = "Sin acceso a Internet. Falló el diagnóstico DNS y dominios por defecto (${fallbackResult.attemptedHosts.joinToString()})." + } + } + } + } + } + + fun loadData() { + isLoadingData = true + errorLoadingData = false PokemonAPI.loadPokemon({ pokemon -> pokemonList = pokemon + isLoadingData = false + errorLoadingData = false }, { - println("Error") + isLoadingData = false + errorLoadingData = true }) } + override fun onCleared() { + stopObservingNetwork() + super.onCleared() + } } \ No newline at end of file diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt new file mode 100644 index 0000000..aa7100f --- /dev/null +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt @@ -0,0 +1,44 @@ +package com.mouredev.pokemonjetpackcompose.util + +import android.content.Context + +/** + * Manages tiered connectivity checks for the Pokemon app. + * + * Tier 1 (Primary): Probes the app's backend domains (pokeapi.co, raw.githubusercontent.com). + * Tier 2 (Extreme / Diagnostic Fallback): Probes public DNS resolvers and default global hosts + * to determine whether an issue is specific to the PokeAPI backend or a general Internet outage. + */ +object AppConnectivityManager { + + val APP_ENDPOINTS = listOf( + "https://pokeapi.co/api/v2/pokemon", + "https://pokeapi.co/", + "https://raw.githubusercontent.com/" + ) + + private val appConnectivity = ConnectivityAndInternetAccess.Builder() + .setHosts(APP_ENDPOINTS) + .setDnsResolvers(emptyList()) // DNS phase disabled for pure app HTTP/HTTPS reachability + .build() + + private val fallbackConnectivity = ConnectivityAndInternetAccess.Builder().build() + + fun checkAppEndpointsAsync( + context: Context, + onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit + ): ConnectivityAndInternetAccess.Request { + return appConnectivity.checkInternetAsync(context) { result -> + onResult(result) + } + } + + fun checkExtremeFallbackAsync( + context: Context, + onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit + ): ConnectivityAndInternetAccess.Request { + return fallbackConnectivity.checkInternetAsync(context) { result -> + onResult(result) + } + } +} diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt new file mode 100644 index 0000000..52e5443 --- /dev/null +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -0,0 +1,1634 @@ +/* + * SPDX-License-Identifier: MIT + * + * Based on Connectivity.java by Emil Davtyan (emil2k), later modified by str4d. + * Further modernized for thread-safety, Android API compatibility, + * extensible probe strategies, and captive-portal-aware reachability checks. + */ +@file:Suppress("DEPRECATION") + +package com.mouredev.pokemonjetpackcompose.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.provider.Settings +import android.telephony.TelephonyManager +import java.io.Closeable +import java.io.IOException +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URL +import java.net.URLConnection +import java.security.GeneralSecurityException +import java.util.ArrayDeque +import java.util.concurrent.Callable +import java.util.concurrent.CancellationException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorCompletionService +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +class ConnectivityAndInternetAccess private constructor( + private val instanceHosts: List, + private val instanceResolvers: List, + private val instanceDnsStrategy: DnsProbeStrategy, + private val instanceHttpStrategy: HttpProbeStrategy +) { + + /** + * Legacy constructor retained for compatibility. It also updates the global host list, + * matching the historical mutable-global behavior. New code should prefer Builder. + */ + constructor(hosts: ArrayList) : this( + normalizeHosts(hosts), + DEFAULT_DNS_RESOLVERS, + DefaultDnsProbe(), + DefaultHttpProbe() + ) { + globalHosts = instanceHosts + } + + fun interface DnsProbeStrategy { + fun checkDns(resolver: String, network: Network?): Boolean + } + + fun interface HttpProbeStrategy { + fun checkHttp(address: String, network: Network?): Boolean + } + + fun interface InternetCallback { + fun onResult(result: InternetResult) + } + + data class InternetResult internal constructor( + val reachable: Boolean, + val reachedHost: String?, + val attemptedHosts: List, + val elapsedMilliseconds: Long + ) { + fun isReachable(): Boolean = reachable + } + + class Request internal constructor() { + private val cancelled = AtomicBoolean(false) + + @Volatile + private var future: Future<*>? = null + + fun cancel() { + cancelled.set(true) + future?.cancel(true) + } + + fun isCancelled(): Boolean = cancelled.get() + + internal fun attach(task: Future<*>) { + future = task + if (cancelled.get()) { + task.cancel(true) + } + } + } + + fun interface NetworkStateCallback { + fun onStateChanged(state: NetworkState) + } + + /** Cheap passive state of the application's default network. */ + data class NetworkState internal constructor( + val connected: Boolean, + val internetValidated: Boolean, + val captivePortalDetected: Boolean, + val observedAtElapsedRealtime: Long + ) { + internal fun sameConnectivityState(other: NetworkState): Boolean = + connected == other.connected && + internetValidated == other.internetValidated && + captivePortalDetected == other.captivePortalDetected + } + + /** + * Lifecycle-friendly passive observer. API 24+ uses the application's default + * NetworkCallback. API 16-23 uses a dynamically registered CONNECTIVITY_ACTION + * receiver because registerDefaultNetworkCallback() did not exist before API 24. + * The observer itself sends no DNS or HTTP traffic. + */ + class NetworkObserver internal constructor( + context: Context, + private val callback: NetworkStateCallback + ) : Closeable { + private val applicationContext: Context = + context.applicationContext ?: context + private val connectivityManager = manager(applicationContext) + private val closed = AtomicBoolean(false) + + @Volatile + private var latestState: NetworkState = snapshotNetworkState(applicationContext) + + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var legacyReceiver: BroadcastReceiver? = null + + init { + deliver(latestState) + register() + } + + fun getLatestState(): NetworkState = latestState + + private fun register() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val observerCallback = object : ConnectivityManager.NetworkCallback() { + @Volatile + private var currentDefaultNetwork: Network? = null + + override fun onAvailable(network: Network) { + // Android explicitly recommends waiting for onCapabilitiesChanged + // instead of synchronously querying capabilities from here. + currentDefaultNetwork = network + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + currentDefaultNetwork = network + publish(networkStateFromCapabilities(networkCapabilities)) + } + + override fun onLost(network: Network) { + if (network == currentDefaultNetwork) { + currentDefaultNetwork = null + publish(disconnectedNetworkState()) + } + } + } + networkCallback = observerCallback + connectivityManager.registerDefaultNetworkCallback(observerCallback) + return + } + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + publish(snapshotNetworkState(applicationContext)) + } + } + legacyReceiver = receiver + applicationContext.registerReceiver( + receiver, + IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + ) + } + + private fun publish(state: NetworkState) { + if (closed.get()) return + val previous = latestState + latestState = state + if (!state.sameConnectivityState(previous)) { + deliver(state) + } + } + + private fun deliver(state: NetworkState) { + mainHandler.post { + if (!closed.get()) { + callback.onStateChanged(state) + } + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + + networkCallback?.let { registered -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + connectivityManager.unregisterNetworkCallback(registered) + } catch (_: IllegalArgumentException) { + // Already unregistered or registration failed during teardown. + } + } + } + networkCallback = null + + legacyReceiver?.let { receiver -> + try { + applicationContext.unregisterReceiver(receiver) + } catch (_: IllegalArgumentException) { + // Receiver was already unregistered. + } + } + legacyReceiver = null + } + } + + class Builder { + private var hosts: List = DEFAULT_HOSTS + private var dnsResolvers: List = DEFAULT_DNS_RESOLVERS + private var dnsStrategy: DnsProbeStrategy = DefaultDnsProbe() + private var httpStrategy: HttpProbeStrategy = DefaultHttpProbe() + + fun setHosts(hosts: List) = apply { + this.hosts = hosts + } + + fun setDnsResolvers(resolvers: List) = apply { + this.dnsResolvers = resolvers + } + + fun setDnsProbeStrategy(strategy: DnsProbeStrategy) = apply { + this.dnsStrategy = strategy + } + + fun setHttpProbeStrategy(strategy: HttpProbeStrategy) = apply { + this.httpStrategy = strategy + } + + fun build(): ConnectivityAndInternetAccess = ConnectivityAndInternetAccess( + normalizeHosts(hosts), + normalizeDnsResolvers(dnsResolvers), + dnsStrategy, + httpStrategy + ) + } + + // Instance API: preferred for new code. + + fun checkInternetAsync( + context: Context, + callback: InternetCallback + ): Request = executeAsync( + context, + instanceResolvers, + instanceHosts, + instanceDnsStrategy, + instanceHttpStrategy, + callback + ) + + fun checkInternetBlocking(context: Context): InternetResult = executeBlocking( + context, + instanceResolvers, + instanceHosts, + instanceDnsStrategy, + instanceHttpStrategy + ) + + companion object { + private const val MINIMUM_FAST_KBPS = 3_072 + private const val CONNECT_TIMEOUT_MS = 800 + private const val READ_TIMEOUT_MS = 800 + private const val DNS_TIMEOUT_MS = 650 + private const val EFFECTIVE_DNS_STAGE_TIMEOUT_MS = 350L + private const val DNS_STAGE_TIMEOUT_MS = 700L + private const val TOTAL_PROBE_TIMEOUT_MS = 2_000L + private const val MAX_PARALLEL_PROBES = 9 + private const val DNS_PORT = 53 + private const val CONNECTION_ATTEMPT_TIMEOUT_MS = 30_000L + private const val DNS_QUERY_NAME = "example.com" + + private val DEFAULT_DNS_RESOLVERS = listOf( + "1.1.1.1", + "8.8.8.8", + "9.9.9.9", + "208.67.222.222" + ) + + private val DEFAULT_HOSTS = listOf( + "https://www.google.com/generate_204", + "https://www.facebook.com/", + "https://www.wolframalpha.com/", + "https://www.apple.com/", + "https://www.amazon.com/" + ) + + @Volatile + private var globalHosts: List = DEFAULT_HOSTS + + @Volatile + private var globalResolvers: List = DEFAULT_DNS_RESOLVERS + + @Volatile + private var globalDnsStrategy: DnsProbeStrategy = DefaultDnsProbe() + + @Volatile + private var globalHttpStrategy: HttpProbeStrategy = DefaultHttpProbe() + + private val connectionAttemptLock = Any() + private val connectionAttemptQueue = ArrayDeque() + private val connectionAttempts = AtomicInteger(0) + private val dnsTransactionId = AtomicInteger(System.nanoTime().toInt()) + private val probeThreadNumber = AtomicInteger(0) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val tls12SocketFactory: SSLSocketFactory? = createTls12Factory() + + private val executor = Executors.newCachedThreadPool(object : ThreadFactory { + private var number = 0 + + @Synchronized + override fun newThread(runnable: Runnable): Thread = + Thread(runnable, "connectivity-check-${++number}").apply { + isDaemon = true + } + }) + + @JvmStatic + fun strictCaptivePortalBuilder(): Builder = Builder() + .setDnsResolvers(emptyList()) + .setHosts( + listOf("https://connectivitycheck.gstatic.com/generate_204") + ) + .setHttpProbeStrategy(StrictHttpProbe()) + + @JvmStatic + fun isActiveNetworkConnected(context: Context): Boolean = isConnected(context) + + @JvmStatic + fun isConnected(context: Context, network: Network?): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return isConnected(context) + } + if (network == null) { + return false + } + return manager(context).getNetworkCapabilities(network).isUsable() + } + + @JvmStatic + fun isConnecting(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") + + if (isConnected(context)) { + return false + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + if (legacyNetworks(manager(context)).any { info -> + info != null && + info.isAvailable && + info.state == NetworkInfo.State.CONNECTING + } + ) { + return true + } + } + + return connectionAttempts.get() > 0 + } + + @JvmStatic + fun beginConnectionAttempt(context: Context) { + // Keep the context parameter for API compatibility and its non-null contract. + context.applicationContext + + val attempt = ConnectionAttempt() + synchronized(connectionAttemptLock) { + connectionAttemptQueue.addLast(attempt) + connectionAttempts.incrementAndGet() + } + + mainHandler.postDelayed( + { closeConnectionAttempt(attempt) }, + CONNECTION_ATTEMPT_TIMEOUT_MS + ) + } + + @JvmStatic + fun endConnectionAttempt() { + synchronized(connectionAttemptLock) { + while (connectionAttemptQueue.isNotEmpty()) { + val attempt = connectionAttemptQueue.removeFirst() + if (!attempt.closed) { + attempt.closed = true + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } + return + } + } + } + } + + @JvmStatic + fun isConnectedOrConnecting(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") + + if (isConnected(context)) { + return true + } + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + if (legacyNetworks(manager(context)).any { info -> + info != null && + info.isAvailable && + info.isConnectedOrConnecting + } + ) { + return true + } + } + + return connectionAttempts.get() > 0 + } + + @JvmStatic + fun isConnected(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") + val connectivityManager = manager(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val active = connectivityManager.activeNetwork + if (active != null && + connectivityManager.getNetworkCapabilities(active).isUsable() + ) { + clearConnectionAttempts() + return true + } + return false + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network).isUsable() + } + ) { + clearConnectionAttempts() + return true + } + return false + } + + val connected = connectivityManager.activeNetworkInfo.isConnectedLegacy() + if (connected) { + clearConnectionAttempts() + } + return connected + } + + /** Cheap point-in-time snapshot of the application's default network. */ + @JvmStatic + fun snapshotNetworkState(context: Context): NetworkState { + val connectivityManager = manager(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val active = connectivityManager.activeNetwork + ?: return disconnectedNetworkState() + return networkStateFromCapabilities( + connectivityManager.getNetworkCapabilities(active) + ) + } + + val connected = connectivityManager.activeNetworkInfo.isConnectedLegacy() + return NetworkState( + connected = connected, + internetValidated = false, + captivePortalDetected = false, + observedAtElapsedRealtime = SystemClock.elapsedRealtime() + ) + } + + /** + * Starts passive default-network observation and immediately posts the current + * state to the main thread. Close the returned observer when no longer needed. + */ + @JvmStatic + fun observeNetwork( + context: Context, + callback: NetworkStateCallback + ): NetworkObserver = NetworkObserver(context, callback) + + /** + * Returns whether Android most recently validated general Internet access on the + * application's current default network. This is a system snapshot, not a fresh + * reachability probe. API levels below 23 do not expose VALIDATED and return false. + */ + @JvmStatic + fun isInternetValidated(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false + } + + val connectivityManager = manager(context) + val active = connectivityManager.activeNetwork ?: return false + return isInternetValidated(context, active) + } + + /** Network-specific variant of [isInternetValidated]. */ + @JvmStatic + fun isInternetValidated(context: Context, network: Network?): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || network == null) { + return false + } + + val capabilities = manager(context).getNetworkCapabilities(network) + return capabilities != null && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + /** + * Returns whether Android detected a captive portal on the application's current + * default network the last time that network was probed. API levels below 23 do not + * expose CAPTIVE_PORTAL and return false. + */ + @JvmStatic + fun isCaptivePortalDetected(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false + } + + val connectivityManager = manager(context) + val active = connectivityManager.activeNetwork ?: return false + return isCaptivePortalDetected(context, active) + } + + /** Network-specific variant of [isCaptivePortalDetected]. */ + @JvmStatic + fun isCaptivePortalDetected(context: Context, network: Network?): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || network == null) { + return false + } + + val capabilities = manager(context).getNetworkCapabilities(network) + return capabilities?.hasCapability( + NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL + ) == true + } + + @JvmStatic + fun isConnectedWifi(context: Context?): Boolean = + hasTransport(context, NetworkCapabilities.TRANSPORT_WIFI) + + @JvmStatic + fun isConnectedWifi(context: Context, network: Network?): Boolean = + hasTransport(context, network, NetworkCapabilities.TRANSPORT_WIFI) + + @JvmStatic + fun isConnectedWifiOverAirplaneMode(context: Context): Boolean = + isAirplaneModeOn(context) && isConnectedWifi(context) + + @JvmStatic + fun isConnectedWifiOverAirplaneMode( + context: Context, + network: Network? + ): Boolean = isAirplaneModeOn(context) && isConnectedWifi(context, network) + + @JvmStatic + fun isConnectedMobileTelephonyManager(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return isConnectedMobile(context) + } + + return try { + val telephonyManager = + context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + telephonyManager?.dataState == TelephonyManager.DATA_CONNECTED + } catch (_: SecurityException) { + isConnectedMobile(context) + } + } + + @JvmStatic + fun isConnectedMobile(context: Context, network: Network?): Boolean = + hasTransport(context, network, NetworkCapabilities.TRANSPORT_CELLULAR) + + @JvmStatic + fun isConnectedMobile(context: Context?): Boolean = + hasTransport(context, NetworkCapabilities.TRANSPORT_CELLULAR) + + @JvmStatic + fun isConnectedEthernet(context: Context?): Boolean = + hasTransport(context, NetworkCapabilities.TRANSPORT_ETHERNET) + + @JvmStatic + fun isConnectedEthernet(context: Context, network: Network?): Boolean = + hasTransport(context, network, NetworkCapabilities.TRANSPORT_ETHERNET) + + @JvmStatic + fun isConnectedFast(context: Context): Boolean { + val connectivityManager = manager(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return connectivityManager.allNetworks.any { network -> + isFast(connectivityManager.getNetworkCapabilities(network)) + } + } + + return legacyNetworks(connectivityManager).any { info -> + info != null && + info.isConnectedLegacy() && + isConnectionFast(info.type, info.subtype) + } + } + + @JvmStatic + fun isConnectedFast(context: Context, network: Network?): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val info = manager(context).activeNetworkInfo + return info != null && + info.isConnectedLegacy() && + isConnectionFast(info.type, info.subtype) + } + + if (network == null) { + return false + } + + return isFast(manager(context).getNetworkCapabilities(network)) + } + + @JvmStatic + fun isAirplaneModeOn(context: Context): Boolean = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + Settings.System.getInt( + context.contentResolver, + Settings.System.AIRPLANE_MODE_ON, + 0 + ) != 0 + } else { + Settings.Global.getInt( + context.contentResolver, + Settings.Global.AIRPLANE_MODE_ON, + 0 + ) != 0 + } + + @JvmStatic + fun vpnActive(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return false + } + + val connectivityManager = manager(context) + return connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + } + } + + // Static compatibility helpers. Their names intentionally differ from + // the instance methods to avoid duplicate JVM signatures. + + @JvmStatic + fun isInternetReachable(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") + return executeBlocking( + context, + globalResolvers, + globalHosts, + globalDnsStrategy, + globalHttpStrategy + ).reachable + } + + @JvmStatic + fun isInternetReachable( + context: Context?, + hosts: ArrayList + ): Boolean { + context ?: throw IllegalArgumentException("context == null") + return executeBlocking( + context, + globalResolvers, + normalizeHosts(hosts), + globalDnsStrategy, + globalHttpStrategy + ).reachable + } + + @JvmStatic + fun checkInternetAsyncDefault( + context: Context, + callback: InternetCallback + ): Request = executeAsync( + context, + globalResolvers, + globalHosts, + globalDnsStrategy, + globalHttpStrategy, + callback + ) + + @JvmStatic + fun checkInternetAsyncDefault( + context: Context, + hosts: List, + callback: InternetCallback + ): Request = executeAsync( + context, + globalResolvers, + hosts, + globalDnsStrategy, + globalHttpStrategy, + callback + ) + + @JvmStatic + fun checkInternetAsyncDefault( + context: Context, + dnsResolvers: List, + hosts: List, + callback: InternetCallback + ): Request = executeAsync( + context, + dnsResolvers, + hosts, + globalDnsStrategy, + globalHttpStrategy, + callback + ) + + @JvmStatic + fun checkInternetBlockingDefault( + context: Context + ): InternetResult = executeBlocking( + context, + globalResolvers, + globalHosts, + globalDnsStrategy, + globalHttpStrategy + ) + + @JvmStatic + fun checkInternetBlockingDefault( + context: Context, + hosts: List + ): InternetResult = executeBlocking( + context, + globalResolvers, + hosts, + globalDnsStrategy, + globalHttpStrategy + ) + + @JvmStatic + fun checkInternetBlockingDefault( + context: Context, + dnsResolvers: List, + hosts: List + ): InternetResult = executeBlocking( + context, + dnsResolvers, + hosts, + globalDnsStrategy, + globalHttpStrategy + ) + + @JvmStatic + fun defaultHosts(): List = DEFAULT_HOSTS + + @JvmStatic + fun defaultDnsResolvers(): List = DEFAULT_DNS_RESOLVERS + + private fun executeAsync( + context: Context, + dnsResolvers: List, + hosts: List, + dnsStrategy: DnsProbeStrategy, + httpStrategy: HttpProbeStrategy, + callback: InternetCallback + ): Request { + val appContext = context.applicationContext ?: context + val normalizedResolvers = normalizeDnsResolvers(dnsResolvers) + val normalizedHosts = normalizeHosts(hosts) + val request = Request() + + request.attach( + executor.submit { + val result = executeBlocking( + appContext, + normalizedResolvers, + normalizedHosts, + dnsStrategy, + httpStrategy + ) + + if (!request.isCancelled()) { + mainHandler.post { + if (!request.isCancelled()) { + callback.onResult(result) + } + } + } + } + ) + + return request + } + + private fun executeBlocking( + context: Context, + dnsResolvers: List, + hosts: List, + dnsStrategy: DnsProbeStrategy, + httpStrategy: HttpProbeStrategy + ): InternetResult { + val started = SystemClock.elapsedRealtime() + val deadline = started + TOTAL_PROBE_TIMEOUT_MS + val attempted = CopyOnWriteArrayList() + val connectivityManager = manager(context) + val network = selectProbeNetwork(connectivityManager) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (network == null) { + return InternetResult( + false, + null, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } + } else if (!isConnected(context)) { + return InternetResult( + false, + null, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } + + val probeExecutor = newProbeExecutor() + try { + val normalizedResolvers = normalizeDnsResolvers(dnsResolvers) + var reached: String? + + /* + * Prefer the DNS configuration of the selected Android Network before + * contacting public resolvers directly. This respects the effective + * network path (including VPN and Private DNS on modern Android). + * + * An empty resolver list still disables the entire DNS stage, preserving + * the historical Builder semantics and strict captive-portal mode. + * A custom DnsProbeStrategy also owns the DNS stage completely, so the + * built-in effective-DNS preflight is only used with DefaultDnsProbe. + */ + if (normalizedResolvers.isNotEmpty() && + dnsStrategy is DefaultDnsProbe + ) { + reached = raceProbes( + listOf( + ProbeAttempt(effectiveDnsLabel()) { + checkEffectiveDns(network) + } + ), + attempted, + minOf( + deadline, + started + EFFECTIVE_DNS_STAGE_TIMEOUT_MS + ), + probeExecutor + ) + + if (reached != null) { + return InternetResult( + true, + reached, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } + } + + val dnsAttempts = normalizedResolvers.map { resolver -> + ProbeAttempt(dnsEndpointLabel(resolver)) { + dnsStrategy.checkDns(resolver, network) + } + } + + reached = raceProbes( + dnsAttempts, + attempted, + minOf(deadline, started + DNS_STAGE_TIMEOUT_MS), + probeExecutor + ) + + if (reached != null) { + return InternetResult( + true, + reached, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } + + val hostAttempts = normalizeHosts(hosts).map { host -> + ProbeAttempt(host) { + httpStrategy.checkHttp(host, network) + } + } + + reached = raceProbes( + hostAttempts, + attempted, + deadline, + probeExecutor + ) + + if (reached != null) { + return InternetResult( + true, + reached, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } + + return InternetResult( + false, + null, + attempted.toList(), + SystemClock.elapsedRealtime() - started + ) + } finally { + probeExecutor.shutdownNow() + } + } + + private fun raceProbes( + probes: List, + attempted: MutableList, + deadline: Long, + probeExecutor: ExecutorService + ): String? { + if (probes.isEmpty() || Thread.currentThread().isInterrupted) { + return null + } + + val completion = ExecutorCompletionService(probeExecutor) + val futures = probes.map { probe -> + completion.submit( + Callable { + attempted += probe.label + if (Thread.currentThread().isInterrupted) { + null + } else if (probe.operation()) { + probe.label + } else { + null + } + } + ) + } + + var remaining = futures.size + try { + while (remaining-- > 0) { + val wait = deadline - SystemClock.elapsedRealtime() + if (wait <= 0) { + return null + } + + val completed = completion.poll(wait, TimeUnit.MILLISECONDS) + ?: return null + + try { + completed.get()?.let { return it } + } catch (_: CancellationException) { + // A failed probe does not fail the whole stage. + } catch (_: ExecutionException) { + // A failed probe does not fail the whole stage. + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + futures.forEach { it.cancel(true) } + } + + return null + } + + private fun checkEffectiveDns(network: Network?): Boolean = + try { + val addresses = if ( + network != null && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ) { + network.getAllByName(DNS_QUERY_NAME) + } else { + InetAddress.getAllByName(DNS_QUERY_NAME) + } + addresses.isNotEmpty() + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } + + private fun effectiveDnsLabel(): String = + "dns://system/$DNS_QUERY_NAME" + + class DefaultDnsProbe : DnsProbeStrategy { + override fun checkDns(resolver: String, network: Network?): Boolean { + val endpoint = parseDnsResolver(resolver) + var socket: DatagramSocket? = null + + return try { + val transactionId = dnsTransactionId.incrementAndGet() and 0xffff + val query = createDnsQuery(transactionId) + + socket = DatagramSocket() + if (network != null && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + ) { + network.bindSocket(socket) + } + + socket.soTimeout = DNS_TIMEOUT_MS + socket.connect(InetSocketAddress(endpoint.host, endpoint.port)) + socket.send(DatagramPacket(query, query.size)) + + val buffer = ByteArray(512) + val response = DatagramPacket(buffer, buffer.size) + socket.receive(response) + + isValidDnsResponse( + transactionId, + response.data, + response.length + ) + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + socket?.close() + } + } + + private fun createDnsQuery(transactionId: Int): ByteArray { + val labels = DNS_QUERY_NAME.split(".") + var length = 12 + 1 + 4 + labels.forEach { label -> + length += 1 + label.length + } + + val query = ByteArray(length) + query[0] = (transactionId ushr 8).toByte() + query[1] = transactionId.toByte() + query[2] = 0x01 + query[5] = 0x01 + + var offset = 12 + for (label in labels) { + query[offset++] = label.length.toByte() + for (character in label) { + query[offset++] = character.code.toByte() + } + } + + query[offset++] = 0x00 + query[offset++] = 0x00 + query[offset++] = 0x01 + query[offset++] = 0x00 + query[offset] = 0x01 + return query + } + + private fun isValidDnsResponse( + transactionId: Int, + response: ByteArray?, + length: Int + ): Boolean { + if (response == null || length < 12) { + return false + } + + val responseId = + ((response[0].toInt() and 0xff) shl 8) or + (response[1].toInt() and 0xff) + val flags = + ((response[2].toInt() and 0xff) shl 8) or + (response[3].toInt() and 0xff) + val questionCount = + ((response[4].toInt() and 0xff) shl 8) or + (response[5].toInt() and 0xff) + val responseCode = flags and 0x000f + + return responseId == transactionId && + (flags and 0x8000) != 0 && + (flags and 0x7800) == 0 && + questionCount > 0 && + responseCode <= 5 + } + } + + class DefaultHttpProbe : HttpProbeStrategy { + override fun checkHttp(address: String, network: Network?): Boolean { + var connection: HttpURLConnection? = null + + return try { + val url = URL(address) + val raw: URLConnection = + if (network != null && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ) { + network.openConnection(url) + } else { + url.openConnection() + } + + connection = raw as HttpURLConnection + configureTlsIfNecessary(connection) + + connection.requestMethod = "GET" + connection.instanceFollowRedirects = false + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.useCaches = false + connection.setRequestProperty("Accept", "*/*") + connection.setRequestProperty("Accept-Encoding", "identity") + connection.setRequestProperty("Connection", "close") + connection.setRequestProperty( + "User-Agent", + "ConnectivityAndInternetAccess/5" + ) + + connection.responseCode in 100..599 + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + connection?.disconnect() + } + } + } + + class StrictHttpProbe : HttpProbeStrategy { + override fun checkHttp(address: String, network: Network?): Boolean { + var connection: HttpURLConnection? = null + + return try { + val url = URL(address) + val raw: URLConnection = + if (network != null && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ) { + network.openConnection(url) + } else { + url.openConnection() + } + + connection = raw as HttpURLConnection + configureTlsIfNecessary(connection) + + connection.requestMethod = "GET" + connection.instanceFollowRedirects = false + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.useCaches = false + connection.setRequestProperty("Accept", "*/*") + connection.setRequestProperty("Accept-Encoding", "identity") + connection.setRequestProperty("Connection", "close") + connection.setRequestProperty( + "User-Agent", + "ConnectivityAndInternetAccess/5" + ) + + val response = connection.responseCode + if (address.contains("generate_204")) { + response == HttpURLConnection.HTTP_NO_CONTENT + } else { + response == HttpURLConnection.HTTP_OK || + response == HttpURLConnection.HTTP_NO_CONTENT + } + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + connection?.disconnect() + } + } + } + + private fun configureTlsIfNecessary(connection: HttpURLConnection) { + if (connection is HttpsURLConnection && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && + Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + ) { + tls12SocketFactory?.let { factory -> + connection.sslSocketFactory = factory + } + } + } + + private fun networkStateFromCapabilities( + capabilities: NetworkCapabilities? + ): NetworkState { + val connected = capabilities.isUsable() + val validated = connected && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + capabilities?.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) == true + val captivePortal = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + capabilities?.hasCapability( + NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL + ) == true + return NetworkState( + connected = connected, + internetValidated = validated, + captivePortalDetected = captivePortal, + observedAtElapsedRealtime = SystemClock.elapsedRealtime() + ) + } + + private fun disconnectedNetworkState(): NetworkState = NetworkState( + connected = false, + internetValidated = false, + captivePortalDetected = false, + observedAtElapsedRealtime = SystemClock.elapsedRealtime() + ) + + private fun manager(context: Context): ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: throw IllegalStateException("ConnectivityManager unavailable") + + private fun NetworkCapabilities?.isUsable(): Boolean { + if (this == null || + !hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + ) { + return false + } + + return Build.VERSION.SDK_INT < Build.VERSION_CODES.P || + hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + } + + private fun hasTransport(context: Context?, transport: Int): Boolean { + context ?: throw IllegalArgumentException("context == null") + val connectivityManager = manager(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return connectivityManager.allNetworks.any { network -> + val capabilities = + connectivityManager.getNetworkCapabilities(network) + capabilities.isUsable() && + capabilities?.hasTransport(transport) == true + } + } + + return legacyNetworks(connectivityManager).any { info -> + info != null && + info.isConnectedLegacy() && + legacyTypeMatches(info.type, transport) + } + } + + private fun hasTransport( + context: Context, + network: Network?, + transport: Int + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return hasTransport(context, transport) + } + + if (network == null) { + return false + } + + val capabilities = manager(context).getNetworkCapabilities(network) + return capabilities.isUsable() && + capabilities?.hasTransport(transport) == true + } + + private fun legacyTypeMatches(type: Int, transport: Int): Boolean = + when (transport) { + NetworkCapabilities.TRANSPORT_WIFI -> + type == ConnectivityManager.TYPE_WIFI + + NetworkCapabilities.TRANSPORT_CELLULAR -> + type == ConnectivityManager.TYPE_MOBILE + + NetworkCapabilities.TRANSPORT_ETHERNET -> + type == ConnectivityManager.TYPE_ETHERNET + + else -> false + } + + private fun isFast(capabilities: NetworkCapabilities?): Boolean = + capabilities.isUsable() && + capabilities!!.linkDownstreamBandwidthKbps >= MINIMUM_FAST_KBPS && + capabilities.linkUpstreamBandwidthKbps >= MINIMUM_FAST_KBPS + + private fun legacyNetworks( + connectivityManager: ConnectivityManager + ): Array = connectivityManager.allNetworkInfo ?: emptyArray() + + private fun NetworkInfo?.isConnectedLegacy(): Boolean = + this != null && isAvailable && isConnected + + private fun isConnectionFast(type: Int, subType: Int): Boolean { + if (type == ConnectivityManager.TYPE_WIFI || + type == ConnectivityManager.TYPE_ETHERNET + ) { + return true + } + + if (type != ConnectivityManager.TYPE_MOBILE) { + return false + } + + return when (subType) { + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_EHRPD, + TelephonyManager.NETWORK_TYPE_EVDO_B, + TelephonyManager.NETWORK_TYPE_HSPAP, + TelephonyManager.NETWORK_TYPE_LTE -> true + + else -> false + } + } + + private fun selectProbeNetwork( + connectivityManager: ConnectivityManager + ): Network? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val active = connectivityManager.activeNetwork + if (active != null && + connectivityManager.getNetworkCapabilities(active).isUsable() + ) { + return active + } + return null + } + + return connectivityManager.allNetworks.firstOrNull { network -> + connectivityManager.getNetworkCapabilities(network).isUsable() + } + } + + private fun normalizeHosts(hosts: List): List { + val normalized = LinkedHashSet() + + for (raw in hosts) { + val trimmed = raw.trim() + if (trimmed.isEmpty()) { + continue + } + + val value = + if (trimmed.startsWith("https://", ignoreCase = true) || + trimmed.startsWith("http://", ignoreCase = true) + ) { + trimmed + } else { + "https://$trimmed/" + } + + require(isValidURL(value)) { + "Invalid HTTP(S) URL: $value" + } + normalized += value + } + + require(normalized.isNotEmpty()) { + "hosts cannot be empty" + } + + return normalized.toList() + } + + private fun normalizeDnsResolvers(resolvers: List): List { + val normalized = LinkedHashSet() + + for (raw in resolvers) { + val value = raw.trim() + if (value.isNotEmpty()) { + parseDnsResolver(value) + normalized += value + } + } + + return normalized.toList() + } + + private fun isValidURL(address: String?): Boolean { + address ?: throw IllegalArgumentException("url == null") + + return try { + val parsed = URL(address) + parsed.toURI() + (parsed.protocol.equals("http", ignoreCase = true) || + parsed.protocol.equals("https", ignoreCase = true)) && + parsed.host.isNotEmpty() + } catch (_: Exception) { + false + } + } + + private fun dnsEndpointLabel(resolver: String): String { + val endpoint = parseDnsResolver(resolver) + val host = if (':' in endpoint.host) { + "[${endpoint.host}]" + } else { + endpoint.host + } + return "dns://$host:${endpoint.port}" + } + + private fun parseDnsResolver(resolver: String): DnsResolver { + val value = resolver.trim() + require(value.isNotEmpty()) { + "Invalid DNS resolver" + } + + var host: String + var port = DNS_PORT + + if (value.startsWith("[")) { + val closingBracket = value.indexOf(']') + require(closingBracket > 1) { + "Invalid DNS resolver" + } + + host = value.substring(1, closingBracket).trim() + val remainder = value.substring(closingBracket + 1).trim() + if (remainder.isNotEmpty()) { + require(remainder.startsWith(":")) { + "Invalid DNS resolver" + } + port = parsePort(remainder.substring(1)) + } + } else { + val firstColon = value.indexOf(':') + val lastColon = value.lastIndexOf(':') + + if (firstColon > 0 && firstColon == lastColon) { + host = value.substring(0, firstColon).trim() + port = parsePort(value.substring(firstColon + 1)) + } else { + host = value + } + } + + require(host.isNotEmpty() && port in 1..65_535) { + "Invalid DNS resolver" + } + + return DnsResolver(host, port) + } + + private fun parsePort(rawPort: String): Int { + val port = rawPort.trim().toIntOrNull() + ?: throw IllegalArgumentException("Invalid DNS resolver port") + require(port in 1..65_535) { + "Invalid DNS resolver port" + } + return port + } + + private fun newProbeExecutor(): ExecutorService = + Executors.newFixedThreadPool( + MAX_PARALLEL_PROBES, + ThreadFactory { runnable -> + Thread( + runnable, + "connectivity-probe-${probeThreadNumber.incrementAndGet()}" + ).apply { + isDaemon = true + } + } + ) + + private fun closeConnectionAttempt(attempt: ConnectionAttempt): Boolean { + synchronized(connectionAttemptLock) { + if (attempt.closed) { + return false + } + + attempt.closed = true + connectionAttemptQueue.remove(attempt) + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } + return true + } + } + + private fun clearConnectionAttempts() { + synchronized(connectionAttemptLock) { + connectionAttemptQueue.forEach { attempt -> + attempt.closed = true + } + connectionAttemptQueue.clear() + connectionAttempts.set(0) + } + } + + private data class ProbeAttempt( + val label: String, + val operation: () -> Boolean + ) + + private data class DnsResolver( + val host: String, + val port: Int + ) + + private class ConnectionAttempt { + var closed: Boolean = false + } + + private fun createTls12Factory(): SSLSocketFactory? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN || + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ) { + return null + } + + return try { + val context = SSLContext.getInstance("TLSv1.2") + context.init(null, null, null) + Tls12SocketFactory(context.socketFactory) + } catch (_: GeneralSecurityException) { + null + } + } + + private class Tls12SocketFactory( + private val delegate: SSLSocketFactory + ) : SSLSocketFactory() { + + override fun getDefaultCipherSuites(): Array = + delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = + delegate.supportedCipherSuites + + override fun createSocket( + socket: Socket, + host: String, + port: Int, + autoClose: Boolean + ): Socket = enable(delegate.createSocket(socket, host, port, autoClose)) + + override fun createSocket(host: String, port: Int): Socket = + enable(delegate.createSocket(host, port)) + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int + ): Socket = enable(delegate.createSocket(host, port, localHost, localPort)) + + override fun createSocket(host: InetAddress, port: Int): Socket = + enable(delegate.createSocket(host, port)) + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int + ): Socket = enable( + delegate.createSocket(address, port, localAddress, localPort) + ) + + private fun enable(socket: Socket): Socket { + if (socket is SSLSocket && + socket.supportedProtocols.contains("TLSv1.2") + ) { + socket.enabledProtocols = arrayOf("TLSv1.2") + } + return socket + } + } + } +} diff --git a/PokemonJetpackCompose/gradle/wrapper/gradle-wrapper.properties b/PokemonJetpackCompose/gradle/wrapper/gradle-wrapper.properties index f931d67..34e03ef 100644 --- a/PokemonJetpackCompose/gradle/wrapper/gradle-wrapper.properties +++ b/PokemonJetpackCompose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Oct 28 22:03:55 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 6b554f02447601584f3285f5c63888a68d1d7bcd Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:48:28 +0200 Subject: [PATCH 2/8] Update connectivity diagnostics to published Gist --- .../util/ConnectivityAndInternetAccess.kt | 386 +++++++++++++++++- 1 file changed, 366 insertions(+), 20 deletions(-) diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt index 52e5443..7af0304 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -56,7 +56,8 @@ class ConnectivityAndInternetAccess private constructor( private val instanceHosts: List, private val instanceResolvers: List, private val instanceDnsStrategy: DnsProbeStrategy, - private val instanceHttpStrategy: HttpProbeStrategy + private val instanceHttpStrategy: HttpProbeStrategy, + private val instanceIcmpTargets: List ) { /** @@ -67,7 +68,8 @@ class ConnectivityAndInternetAccess private constructor( normalizeHosts(hosts), DEFAULT_DNS_RESOLVERS, DefaultDnsProbe(), - DefaultHttpProbe() + DefaultHttpProbe(), + DEFAULT_ICMP_TARGETS ) { globalHosts = instanceHosts } @@ -84,6 +86,16 @@ class ConnectivityAndInternetAccess private constructor( fun onResult(result: InternetResult) } + /** + * Receives the result of the optional ICMP diagnostic. + * + * ICMP is deliberately independent from the normal DNS/HTTP reachability + * result. A failed ICMP probe does not mean that Internet access is unavailable. + */ + fun interface IcmpCallback { + fun onResult(result: IcmpResult) + } + data class InternetResult internal constructor( val reachable: Boolean, val reachedHost: String?, @@ -93,6 +105,21 @@ class ConnectivityAndInternetAccess private constructor( fun isReachable(): Boolean = reachable } + /** + * Result of the optional ICMP diagnostic. + * + * This is not an authoritative Internet-availability signal because many + * otherwise functional networks deliberately filter ICMP. + */ + data class IcmpResult internal constructor( + val reachable: Boolean, + val reachedAddress: String?, + val attemptedAddresses: List, + val elapsedMilliseconds: Long + ) { + fun isReachable(): Boolean = reachable + } + class Request internal constructor() { private val cancelled = AtomicBoolean(false) @@ -166,8 +193,6 @@ class ConnectivityAndInternetAccess private constructor( private var currentDefaultNetwork: Network? = null override fun onAvailable(network: Network) { - // Android explicitly recommends waiting for onCapabilitiesChanged - // instead of synchronously querying capabilities from here. currentDefaultNetwork = network } @@ -248,6 +273,7 @@ class ConnectivityAndInternetAccess private constructor( class Builder { private var hosts: List = DEFAULT_HOSTS private var dnsResolvers: List = DEFAULT_DNS_RESOLVERS + private var icmpTargets: List = DEFAULT_ICMP_TARGETS private var dnsStrategy: DnsProbeStrategy = DefaultDnsProbe() private var httpStrategy: HttpProbeStrategy = DefaultHttpProbe() @@ -259,6 +285,14 @@ class ConnectivityAndInternetAccess private constructor( this.dnsResolvers = resolvers } + /** + * Configures targets used only by the explicit ICMP diagnostic. + * Defaults to 1.1.1.1 followed by 8.8.8.8. + */ + fun setIcmpTargets(targets: List) = apply { + this.icmpTargets = targets + } + fun setDnsProbeStrategy(strategy: DnsProbeStrategy) = apply { this.dnsStrategy = strategy } @@ -271,12 +305,11 @@ class ConnectivityAndInternetAccess private constructor( normalizeHosts(hosts), normalizeDnsResolvers(dnsResolvers), dnsStrategy, - httpStrategy + httpStrategy, + normalizeIcmpTargets(icmpTargets) ) } - // Instance API: preferred for new code. - fun checkInternetAsync( context: Context, callback: InternetCallback @@ -297,6 +330,20 @@ class ConnectivityAndInternetAccess private constructor( instanceHttpStrategy ) + /** + * Runs an optional ICMP diagnostic off the caller thread. + * + * This does not participate in checkInternetAsync(). A false result must not + * be interpreted as "offline". The spawned ping process follows the OS routing + * decision and cannot be bound to an Android Network like the DNS/HTTP probes. + */ + fun checkIcmpReachabilityAsync(callback: IcmpCallback): Request = + executeIcmpAsync(instanceIcmpTargets, callback) + + /** Blocking ICMP counterpart. Do not call this from the main thread. */ + fun checkIcmpReachabilityBlocking(): IcmpResult = + executeIcmpBlocking(instanceIcmpTargets) + companion object { private const val MINIMUM_FAST_KBPS = 3_072 private const val CONNECT_TIMEOUT_MS = 800 @@ -306,6 +353,10 @@ class ConnectivityAndInternetAccess private constructor( private const val DNS_STAGE_TIMEOUT_MS = 700L private const val TOTAL_PROBE_TIMEOUT_MS = 2_000L private const val MAX_PARALLEL_PROBES = 9 + private const val ICMP_ATTEMPT_TIMEOUT_MS = 800L + private const val ICMP_TOTAL_TIMEOUT_MS = 1_500L + private const val ICMP_POLL_INTERVAL_MS = 25L + private const val PING_BINARY = "/system/bin/ping" private const val DNS_PORT = 53 private const val CONNECTION_ATTEMPT_TIMEOUT_MS = 30_000L private const val DNS_QUERY_NAME = "example.com" @@ -325,6 +376,15 @@ class ConnectivityAndInternetAccess private constructor( "https://www.amazon.com/" ) + /** + * Numeric addresses avoid requiring forward DNS merely to start the + * built-in IP/ICMP diagnostic. + */ + private val DEFAULT_ICMP_TARGETS = listOf( + "1.1.1.1", + "8.8.8.8" + ) + @Volatile private var globalHosts: List = DEFAULT_HOSTS @@ -340,6 +400,8 @@ class ConnectivityAndInternetAccess private constructor( private val connectionAttemptLock = Any() private val connectionAttemptQueue = ArrayDeque() private val connectionAttempts = AtomicInteger(0) + private val connectionAttemptStalled = AtomicBoolean(false) + private var legacyConnectingSinceElapsedRealtime = -1L private val dnsTransactionId = AtomicInteger(System.nanoTime().toInt()) private val probeThreadNumber = AtomicInteger(0) @@ -387,32 +449,77 @@ class ConnectivityAndInternetAccess private constructor( } if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - if (legacyNetworks(manager(context)).any { info -> - info != null && - info.isAvailable && - info.state == NetworkInfo.State.CONNECTING - } - ) { + val legacyConnecting = isLegacyConnecting(manager(context)) + updateLegacyConnectingStallState(legacyConnecting) + if (legacyConnecting) { return true } } + expireTimedOutConnectionAttempts() return connectionAttempts.get() > 0 } + /** + * Returns true when a connection attempt has remained unresolved for at + * least [CONNECTION_ATTEMPT_TIMEOUT_MS]. + * + * API 29+ uses attempts registered with [beginConnectionAttempt]. API + * 16-28 also times the legacy CONNECTING state from its first observation + * by this helper or [isConnecting]. + */ @JvmStatic - fun beginConnectionAttempt(context: Context) { - // Keep the context parameter for API compatibility and its non-null contract. - context.applicationContext + fun isConnectionAttemptStalled(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") - val attempt = ConnectionAttempt() + if (isConnected(context)) { + return false + } + + expireTimedOutConnectionAttempts() + if (connectionAttemptStalled.get()) { + return true + } + + return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + updateLegacyConnectingStallState( + isLegacyConnecting(manager(context)) + ) + } else { + false + } + } + + @JvmStatic + fun clearConnectionAttemptStall() { + synchronized(connectionAttemptLock) { + connectionAttemptStalled.set(false) + legacyConnectingSinceElapsedRealtime = -1L + } + } + + @JvmStatic + fun beginConnectionAttempt(context: Context) { + val safeContext = context.applicationContext ?: context + val attempt: ConnectionAttempt synchronized(connectionAttemptLock) { + if (connectionAttempts.get() == 0) { + connectionAttemptStalled.set(false) + legacyConnectingSinceElapsedRealtime = -1L + } + // Timestamp at enqueue time so queue order and timeout order cannot + // diverge when several callers begin attempts concurrently. + attempt = ConnectionAttempt(SystemClock.elapsedRealtime()) connectionAttemptQueue.addLast(attempt) connectionAttempts.incrementAndGet() } mainHandler.postDelayed( - { closeConnectionAttempt(attempt) }, + { + if (!isConnected(safeContext)) { + timeoutConnectionAttempt(attempt) + } + }, CONNECTION_ATTEMPT_TIMEOUT_MS ) } @@ -452,6 +559,7 @@ class ConnectivityAndInternetAccess private constructor( } } + expireTimedOutConnectionAttempts() return connectionAttempts.get() > 0 } @@ -798,12 +906,150 @@ class ConnectivityAndInternetAccess private constructor( globalHttpStrategy ) + @JvmStatic + fun checkIcmpReachabilityAsyncDefault( + callback: IcmpCallback + ): Request = executeIcmpAsync(DEFAULT_ICMP_TARGETS, callback) + + @JvmStatic + fun checkIcmpReachabilityBlockingDefault(): IcmpResult = + executeIcmpBlocking(DEFAULT_ICMP_TARGETS) + @JvmStatic fun defaultHosts(): List = DEFAULT_HOSTS @JvmStatic fun defaultDnsResolvers(): List = DEFAULT_DNS_RESOLVERS + @JvmStatic + fun defaultIcmpTargets(): List = DEFAULT_ICMP_TARGETS + + private fun executeIcmpAsync( + targets: List, + callback: IcmpCallback + ): Request { + val normalizedTargets = normalizeIcmpTargets(targets) + val request = Request() + + request.attach( + executor.submit { + val result = executeIcmpBlocking(normalizedTargets) + if (!request.isCancelled()) { + mainHandler.post { + if (!request.isCancelled()) { + callback.onResult(result) + } + } + } + } + ) + + return request + } + + private fun executeIcmpBlocking(targets: List): IcmpResult { + val started = SystemClock.elapsedRealtime() + val deadline = started + ICMP_TOTAL_TIMEOUT_MS + val attempted = mutableListOf() + + for (target in normalizeIcmpTargets(targets)) { + if (Thread.currentThread().isInterrupted || + SystemClock.elapsedRealtime() >= deadline + ) { + break + } + + attempted += target + val attemptDeadline = minOf( + deadline, + SystemClock.elapsedRealtime() + ICMP_ATTEMPT_TIMEOUT_MS + ) + + if (checkIcmpTarget(target, attemptDeadline)) { + return IcmpResult( + reachable = true, + reachedAddress = target, + attemptedAddresses = attempted.toList(), + elapsedMilliseconds = + SystemClock.elapsedRealtime() - started + ) + } + } + + return IcmpResult( + reachable = false, + reachedAddress = null, + attemptedAddresses = attempted.toList(), + elapsedMilliseconds = SystemClock.elapsedRealtime() - started + ) + } + + private fun checkIcmpTarget(target: String, deadline: Long): Boolean { + var process: Process? = null + + return try { + process = startPingProcess(target) + + // ping never needs stdin. + process.outputStream.closeQuietly() + + while (!Thread.currentThread().isInterrupted) { + try { + return process.exitValue() == 0 + } catch (_: IllegalThreadStateException) { + // Still running; enforce an API-16-safe deadline ourselves. + } + + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0) { + return false + } + + try { + Thread.sleep(minOf(ICMP_POLL_INTERVAL_MS, remaining)) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + + false + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + process?.let { running -> + try { + running.destroy() + } catch (_: RuntimeException) { + // Best-effort teardown on unusual OEM implementations. + } + + running.inputStream.closeQuietly() + running.errorStream.closeQuietly() + running.outputStream.closeQuietly() + } + } + } + + private fun startPingProcess(target: String): Process = + try { + ProcessBuilder(PING_BINARY, "-c", "1", target) + .redirectErrorStream(true) + .start() + } catch (primaryFailure: IOException) { + try { + ProcessBuilder("ping", "-c", "1", target) + .redirectErrorStream(true) + .start() + } catch (fallbackFailure: IOException) { + // Keep the fallback path compatible with Android API 16-18; + // the suppressed-exception API is not available there. + throw fallbackFailure + } + } + private fun executeAsync( context: Context, dnsResolvers: List, @@ -1345,6 +1591,34 @@ class ConnectivityAndInternetAccess private constructor( private fun NetworkInfo?.isConnectedLegacy(): Boolean = this != null && isAvailable && isConnected + private fun isLegacyConnecting( + connectivityManager: ConnectivityManager + ): Boolean = legacyNetworks(connectivityManager).any { info -> + info != null && + info.isAvailable && + info.state == NetworkInfo.State.CONNECTING + } + + private fun updateLegacyConnectingStallState( + connecting: Boolean + ): Boolean { + synchronized(connectionAttemptLock) { + if (!connecting) { + legacyConnectingSinceElapsedRealtime = -1L + return false + } + + val now = SystemClock.elapsedRealtime() + if (legacyConnectingSinceElapsedRealtime < 0L) { + legacyConnectingSinceElapsedRealtime = now + return false + } + + return now - legacyConnectingSinceElapsedRealtime >= + CONNECTION_ATTEMPT_TIMEOUT_MS + } + } + private fun isConnectionFast(type: Int, subType: Int): Boolean { if (type == ConnectivityManager.TYPE_WIFI || type == ConnectivityManager.TYPE_ETHERNET @@ -1394,6 +1668,33 @@ class ConnectivityAndInternetAccess private constructor( } } + private fun normalizeIcmpTargets(targets: List): List { + val normalized = LinkedHashSet() + + for (raw in targets) { + val value = raw.trim() + if (value.isEmpty()) { + continue + } + + /* + * ProcessBuilder already avoids shell injection. Validation also + * rejects option-looking values and command/path punctuation while + * retaining IPv4, IPv6 zone identifiers and ordinary host names. + */ + require( + !value.startsWith("-") && + value.matches(Regex("[A-Za-z0-9._:%-]+")) + ) { + "Invalid ICMP target: $value" + } + + normalized += value + } + + return normalized.toList() + } + private fun normalizeHosts(hosts: List): List { val normalized = LinkedHashSet() @@ -1514,6 +1815,18 @@ class ConnectivityAndInternetAccess private constructor( return port } + private fun Closeable?.closeQuietly() { + if (this == null) { + return + } + + try { + close() + } catch (_: IOException) { + // Best-effort process-stream cleanup. + } + } + private fun newProbeExecutor(): ExecutorService = Executors.newFixedThreadPool( MAX_PARALLEL_PROBES, @@ -1527,7 +1840,7 @@ class ConnectivityAndInternetAccess private constructor( } ) - private fun closeConnectionAttempt(attempt: ConnectionAttempt): Boolean { + private fun timeoutConnectionAttempt(attempt: ConnectionAttempt): Boolean { synchronized(connectionAttemptLock) { if (attempt.closed) { return false @@ -1538,10 +1851,38 @@ class ConnectivityAndInternetAccess private constructor( connectionAttempts.updateAndGet { value -> if (value > 0) value - 1 else 0 } + connectionAttemptStalled.set(true) return true } } + private fun expireTimedOutConnectionAttempts() { + val now = SystemClock.elapsedRealtime() + + synchronized(connectionAttemptLock) { + while (connectionAttemptQueue.isNotEmpty()) { + val attempt = connectionAttemptQueue.first() + if (attempt.closed) { + connectionAttemptQueue.removeFirst() + continue + } + if ( + now - attempt.startedAtElapsedRealtime < + CONNECTION_ATTEMPT_TIMEOUT_MS + ) { + break + } + + attempt.closed = true + connectionAttemptQueue.removeFirst() + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } + connectionAttemptStalled.set(true) + } + } + } + private fun clearConnectionAttempts() { synchronized(connectionAttemptLock) { connectionAttemptQueue.forEach { attempt -> @@ -1549,6 +1890,8 @@ class ConnectivityAndInternetAccess private constructor( } connectionAttemptQueue.clear() connectionAttempts.set(0) + connectionAttemptStalled.set(false) + legacyConnectingSinceElapsedRealtime = -1L } } @@ -1562,7 +1905,9 @@ class ConnectivityAndInternetAccess private constructor( val port: Int ) - private class ConnectionAttempt { + private class ConnectionAttempt( + val startedAtElapsedRealtime: Long + ) { var closed: Boolean = false } @@ -1632,3 +1977,4 @@ class ConnectivityAndInternetAccess private constructor( } } } + From 6bbca38f76d0a920d316e73bcc172682b0ecc446 Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:56:32 +0200 Subject: [PATCH 3/8] fix: sign release APK builds --- PokemonJetpackCompose/app/build.gradle | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PokemonJetpackCompose/app/build.gradle b/PokemonJetpackCompose/app/build.gradle index fef8560..63deca6 100644 --- a/PokemonJetpackCompose/app/build.gradle +++ b/PokemonJetpackCompose/app/build.gradle @@ -23,6 +23,10 @@ android { buildTypes { release { minifyEnabled false + // Keep the standalone release APK installable in local/CI builds. + // A distribution keystore can override this with the usual + // release signing configuration when one is available. + signingConfig signingConfigs.debug proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -63,4 +67,4 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" -} \ No newline at end of file +} From ac503d873829c6bc190c15f8e72e1274d86323dd Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:10:15 +0200 Subject: [PATCH 4/8] feat: update connectivity diagnostics from gist --- .../ui/list/PokemonListActivity.kt | 4 +- .../util/ConnectivityAndInternetAccess.kt | 624 +++++++++++++----- 2 files changed, 449 insertions(+), 179 deletions(-) diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt index ff57dc4..8017ba3 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt @@ -123,7 +123,7 @@ fun PokemonList(viewModel: PokemonListViewModel) { ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Orden de comprobación: 1º Endpoints App (pokeapi.co) -> 2º Fallback Extremo (DNS publicos / dominios por defecto).", + text = "Orden: endpoints de la app -> DNS/TCP/NTP/TLS/HTTPS -> fallback general e ICMP opcional.", fontSize = 11.sp, color = Color.Gray ) @@ -283,4 +283,4 @@ fun PokemonListDefaultPreview() { PokemonJetpackComposeTheme { PokemonList(viewModel = PokemonListViewModel()) } -} \ No newline at end of file +} diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt index 7af0304..c34e898 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -55,8 +55,14 @@ import javax.net.ssl.SSLSocketFactory class ConnectivityAndInternetAccess private constructor( private val instanceHosts: List, private val instanceResolvers: List, + private val instanceTcpTargets: List, + private val instanceNtpTargets: List, + private val instanceTlsTargets: List, private val instanceDnsStrategy: DnsProbeStrategy, private val instanceHttpStrategy: HttpProbeStrategy, + private val instanceTcpStrategy: TcpProbeStrategy, + private val instanceNtpStrategy: NtpProbeStrategy, + private val instanceTlsStrategy: TlsProbeStrategy, private val instanceIcmpTargets: List ) { @@ -67,8 +73,14 @@ class ConnectivityAndInternetAccess private constructor( constructor(hosts: ArrayList) : this( normalizeHosts(hosts), DEFAULT_DNS_RESOLVERS, + DEFAULT_TCP_TARGETS, + DEFAULT_NTP_TARGETS, + DEFAULT_TLS_TARGETS, DefaultDnsProbe(), DefaultHttpProbe(), + DefaultTcpProbe(), + DefaultNtpProbe(), + DefaultTlsProbe(), DEFAULT_ICMP_TARGETS ) { globalHosts = instanceHosts @@ -82,6 +94,18 @@ class ConnectivityAndInternetAccess private constructor( fun checkHttp(address: String, network: Network?): Boolean } + fun interface TcpProbeStrategy { + fun checkTcp(host: String, port: Int, network: Network?): Boolean + } + + fun interface NtpProbeStrategy { + fun checkNtp(host: String, network: Network?): Boolean + } + + fun interface TlsProbeStrategy { + fun checkTls(host: String, port: Int, network: Network?): Boolean + } + fun interface InternetCallback { fun onResult(result: InternetResult) } @@ -193,6 +217,8 @@ class ConnectivityAndInternetAccess private constructor( private var currentDefaultNetwork: Network? = null override fun onAvailable(network: Network) { + // Android explicitly recommends waiting for onCapabilitiesChanged + // instead of synchronously querying capabilities from here. currentDefaultNetwork = network } @@ -273,9 +299,15 @@ class ConnectivityAndInternetAccess private constructor( class Builder { private var hosts: List = DEFAULT_HOSTS private var dnsResolvers: List = DEFAULT_DNS_RESOLVERS + private var tcpTargets: List = DEFAULT_TCP_TARGETS + private var ntpTargets: List = DEFAULT_NTP_TARGETS + private var tlsTargets: List = DEFAULT_TLS_TARGETS private var icmpTargets: List = DEFAULT_ICMP_TARGETS private var dnsStrategy: DnsProbeStrategy = DefaultDnsProbe() private var httpStrategy: HttpProbeStrategy = DefaultHttpProbe() + private var tcpStrategy: TcpProbeStrategy = DefaultTcpProbe() + private var ntpStrategy: NtpProbeStrategy = DefaultNtpProbe() + private var tlsStrategy: TlsProbeStrategy = DefaultTlsProbe() fun setHosts(hosts: List) = apply { this.hosts = hosts @@ -285,9 +317,21 @@ class ConnectivityAndInternetAccess private constructor( this.dnsResolvers = resolvers } + fun setTcpTargets(targets: List) = apply { + this.tcpTargets = targets + } + + fun setNtpTargets(targets: List) = apply { + this.ntpTargets = targets + } + + fun setTlsTargets(targets: List) = apply { + this.tlsTargets = targets + } + /** * Configures targets used only by the explicit ICMP diagnostic. - * Defaults to 1.1.1.1 followed by 8.8.8.8. + * Defaults to 1.1.1.1, 8.8.8.8, and the Cloudflare IPv6 resolver address. */ fun setIcmpTargets(targets: List) = apply { this.icmpTargets = targets @@ -301,15 +345,35 @@ class ConnectivityAndInternetAccess private constructor( this.httpStrategy = strategy } + fun setTcpProbeStrategy(strategy: TcpProbeStrategy) = apply { + this.tcpStrategy = strategy + } + + fun setNtpProbeStrategy(strategy: NtpProbeStrategy) = apply { + this.ntpStrategy = strategy + } + + fun setTlsProbeStrategy(strategy: TlsProbeStrategy) = apply { + this.tlsStrategy = strategy + } + fun build(): ConnectivityAndInternetAccess = ConnectivityAndInternetAccess( normalizeHosts(hosts), normalizeDnsResolvers(dnsResolvers), + normalizeEndpointTargets(tcpTargets, HTTPS_PORT, "tcpTargets"), + normalizeNtpTargets(ntpTargets), + normalizeEndpointTargets(tlsTargets, HTTPS_PORT, "tlsTargets"), dnsStrategy, httpStrategy, + tcpStrategy, + ntpStrategy, + tlsStrategy, normalizeIcmpTargets(icmpTargets) ) } + // Instance API: preferred for new code. + fun checkInternetAsync( context: Context, callback: InternetCallback @@ -317,8 +381,14 @@ class ConnectivityAndInternetAccess private constructor( context, instanceResolvers, instanceHosts, + instanceTcpTargets, + instanceNtpTargets, + instanceTlsTargets, instanceDnsStrategy, instanceHttpStrategy, + instanceTcpStrategy, + instanceNtpStrategy, + instanceTlsStrategy, callback ) @@ -326,8 +396,14 @@ class ConnectivityAndInternetAccess private constructor( context, instanceResolvers, instanceHosts, + instanceTcpTargets, + instanceNtpTargets, + instanceTlsTargets, instanceDnsStrategy, - instanceHttpStrategy + instanceHttpStrategy, + instanceTcpStrategy, + instanceNtpStrategy, + instanceTlsStrategy ) /** @@ -346,18 +422,20 @@ class ConnectivityAndInternetAccess private constructor( companion object { private const val MINIMUM_FAST_KBPS = 3_072 - private const val CONNECT_TIMEOUT_MS = 800 - private const val READ_TIMEOUT_MS = 800 - private const val DNS_TIMEOUT_MS = 650 - private const val EFFECTIVE_DNS_STAGE_TIMEOUT_MS = 350L - private const val DNS_STAGE_TIMEOUT_MS = 700L - private const val TOTAL_PROBE_TIMEOUT_MS = 2_000L - private const val MAX_PARALLEL_PROBES = 9 + private const val CONNECT_TIMEOUT_MS = 3_000 + private const val READ_TIMEOUT_MS = 3_000 + private const val DNS_TIMEOUT_MS = 2_500 + private const val EFFECTIVE_DNS_STAGE_TIMEOUT_MS = 1_500L + private const val DNS_STAGE_TIMEOUT_MS = 3_500L + private const val TOTAL_PROBE_TIMEOUT_MS = 6_000L + private const val MAX_PARALLEL_PROBES = 16 private const val ICMP_ATTEMPT_TIMEOUT_MS = 800L private const val ICMP_TOTAL_TIMEOUT_MS = 1_500L private const val ICMP_POLL_INTERVAL_MS = 25L private const val PING_BINARY = "/system/bin/ping" private const val DNS_PORT = 53 + private const val NTP_PORT = 123 + private const val HTTPS_PORT = 443 private const val CONNECTION_ATTEMPT_TIMEOUT_MS = 30_000L private const val DNS_QUERY_NAME = "example.com" @@ -365,7 +443,8 @@ class ConnectivityAndInternetAccess private constructor( "1.1.1.1", "8.8.8.8", "9.9.9.9", - "208.67.222.222" + "208.67.222.222", + "[2606:4700:4700::1111]" ) private val DEFAULT_HOSTS = listOf( @@ -380,9 +459,26 @@ class ConnectivityAndInternetAccess private constructor( * Numeric addresses avoid requiring forward DNS merely to start the * built-in IP/ICMP diagnostic. */ + private val DEFAULT_TCP_TARGETS = listOf( + "1.1.1.1:53", + "8.8.8.8:443", + "[2606:4700:4700::1111]:53" + ) + + private val DEFAULT_NTP_TARGETS = listOf( + "time.google.com", + "pool.ntp.org" + ) + + private val DEFAULT_TLS_TARGETS = listOf( + "www.google.com:443", + "cloudflare.com:443" + ) + private val DEFAULT_ICMP_TARGETS = listOf( "1.1.1.1", - "8.8.8.8" + "8.8.8.8", + "[2606:4700:4700::1111]" ) @Volatile @@ -391,12 +487,29 @@ class ConnectivityAndInternetAccess private constructor( @Volatile private var globalResolvers: List = DEFAULT_DNS_RESOLVERS + @Volatile + private var globalTcpTargets: List = DEFAULT_TCP_TARGETS + + @Volatile + private var globalNtpTargets: List = DEFAULT_NTP_TARGETS + + @Volatile + private var globalTlsTargets: List = DEFAULT_TLS_TARGETS + @Volatile private var globalDnsStrategy: DnsProbeStrategy = DefaultDnsProbe() @Volatile private var globalHttpStrategy: HttpProbeStrategy = DefaultHttpProbe() + @Volatile + private var globalTcpStrategy: TcpProbeStrategy = DefaultTcpProbe() + + @Volatile + private var globalNtpStrategy: NtpProbeStrategy = DefaultNtpProbe() + + @Volatile + private var globalTlsStrategy: TlsProbeStrategy = DefaultTlsProbe() private val connectionAttemptLock = Any() private val connectionAttemptQueue = ArrayDeque() private val connectionAttempts = AtomicInteger(0) @@ -421,6 +534,9 @@ class ConnectivityAndInternetAccess private constructor( @JvmStatic fun strictCaptivePortalBuilder(): Builder = Builder() .setDnsResolvers(emptyList()) + .setTcpTargets(emptyList()) + .setNtpTargets(emptyList()) + .setTlsTargets(emptyList()) .setHosts( listOf("https://connectivitycheck.gstatic.com/generate_204") ) @@ -443,11 +559,9 @@ class ConnectivityAndInternetAccess private constructor( @JvmStatic fun isConnecting(context: Context?): Boolean { context ?: throw IllegalArgumentException("context == null") - if (isConnected(context)) { return false } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { val legacyConnecting = isLegacyConnecting(manager(context)) updateLegacyConnectingStallState(legacyConnecting) @@ -455,11 +569,9 @@ class ConnectivityAndInternetAccess private constructor( return true } } - expireTimedOutConnectionAttempts() return connectionAttempts.get() > 0 } - /** * Returns true when a connection attempt has remained unresolved for at * least [CONNECTION_ATTEMPT_TIMEOUT_MS]. @@ -501,19 +613,14 @@ class ConnectivityAndInternetAccess private constructor( @JvmStatic fun beginConnectionAttempt(context: Context) { val safeContext = context.applicationContext ?: context - val attempt: ConnectionAttempt + val attempt = ConnectionAttempt(SystemClock.elapsedRealtime()) synchronized(connectionAttemptLock) { if (connectionAttempts.get() == 0) { connectionAttemptStalled.set(false) - legacyConnectingSinceElapsedRealtime = -1L } - // Timestamp at enqueue time so queue order and timeout order cannot - // diverge when several callers begin attempts concurrently. - attempt = ConnectionAttempt(SystemClock.elapsedRealtime()) connectionAttemptQueue.addLast(attempt) connectionAttempts.incrementAndGet() } - mainHandler.postDelayed( { if (!isConnected(safeContext)) { @@ -559,7 +666,6 @@ class ConnectivityAndInternetAccess private constructor( } } - expireTimedOutConnectionAttempts() return connectionAttempts.get() > 0 } @@ -808,8 +914,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, globalHosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, - globalHttpStrategy + globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy ).reachable } @@ -823,8 +935,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, normalizeHosts(hosts), + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, - globalHttpStrategy + globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy ).reachable } @@ -836,8 +954,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, globalHosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy, callback ) @@ -850,8 +974,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, hosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy, callback ) @@ -865,8 +995,14 @@ class ConnectivityAndInternetAccess private constructor( context, dnsResolvers, hosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy, callback ) @@ -877,8 +1013,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, globalHosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, - globalHttpStrategy + globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy ) @JvmStatic @@ -889,8 +1031,14 @@ class ConnectivityAndInternetAccess private constructor( context, globalResolvers, hosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, - globalHttpStrategy + globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy ) @JvmStatic @@ -902,8 +1050,14 @@ class ConnectivityAndInternetAccess private constructor( context, dnsResolvers, hosts, + globalTcpTargets, + globalNtpTargets, + globalTlsTargets, globalDnsStrategy, - globalHttpStrategy + globalHttpStrategy, + globalTcpStrategy, + globalNtpStrategy, + globalTlsStrategy ) @JvmStatic @@ -921,6 +1075,15 @@ class ConnectivityAndInternetAccess private constructor( @JvmStatic fun defaultDnsResolvers(): List = DEFAULT_DNS_RESOLVERS + @JvmStatic + fun defaultTcpTargets(): List = DEFAULT_TCP_TARGETS + + @JvmStatic + fun defaultNtpTargets(): List = DEFAULT_NTP_TARGETS + + @JvmStatic + fun defaultTlsTargets(): List = DEFAULT_TLS_TARGETS + @JvmStatic fun defaultIcmpTargets(): List = DEFAULT_ICMP_TARGETS @@ -1035,17 +1198,15 @@ class ConnectivityAndInternetAccess private constructor( private fun startPingProcess(target: String): Process = try { - ProcessBuilder(PING_BINARY, "-c", "1", target) + ProcessBuilder(PING_BINARY, "-c", "1", stripAddressBrackets(target)) .redirectErrorStream(true) .start() } catch (primaryFailure: IOException) { try { - ProcessBuilder("ping", "-c", "1", target) + ProcessBuilder("ping", "-c", "1", stripAddressBrackets(target)) .redirectErrorStream(true) .start() } catch (fallbackFailure: IOException) { - // Keep the fallback path compatible with Android API 16-18; - // the suppressed-exception API is not available there. throw fallbackFailure } } @@ -1054,13 +1215,22 @@ class ConnectivityAndInternetAccess private constructor( context: Context, dnsResolvers: List, hosts: List, + tcpTargets: List, + ntpTargets: List, + tlsTargets: List, dnsStrategy: DnsProbeStrategy, httpStrategy: HttpProbeStrategy, + tcpStrategy: TcpProbeStrategy, + ntpStrategy: NtpProbeStrategy, + tlsStrategy: TlsProbeStrategy, callback: InternetCallback ): Request { val appContext = context.applicationContext ?: context val normalizedResolvers = normalizeDnsResolvers(dnsResolvers) val normalizedHosts = normalizeHosts(hosts) + val normalizedTcpTargets = normalizeEndpointTargets(tcpTargets, HTTPS_PORT, "tcpTargets") + val normalizedNtpTargets = normalizeNtpTargets(ntpTargets) + val normalizedTlsTargets = normalizeEndpointTargets(tlsTargets, HTTPS_PORT, "tlsTargets") val request = Request() request.attach( @@ -1069,8 +1239,14 @@ class ConnectivityAndInternetAccess private constructor( appContext, normalizedResolvers, normalizedHosts, + normalizedTcpTargets, + normalizedNtpTargets, + normalizedTlsTargets, dnsStrategy, - httpStrategy + httpStrategy, + tcpStrategy, + ntpStrategy, + tlsStrategy ) if (!request.isCancelled()) { @@ -1090,8 +1266,14 @@ class ConnectivityAndInternetAccess private constructor( context: Context, dnsResolvers: List, hosts: List, + tcpTargets: List, + ntpTargets: List, + tlsTargets: List, dnsStrategy: DnsProbeStrategy, - httpStrategy: HttpProbeStrategy + httpStrategy: HttpProbeStrategy, + tcpStrategy: TcpProbeStrategy, + ntpStrategy: NtpProbeStrategy, + tlsStrategy: TlsProbeStrategy ): InternetResult { val started = SystemClock.elapsedRealtime() val deadline = started + TOTAL_PROBE_TIMEOUT_MS @@ -1101,20 +1283,10 @@ class ConnectivityAndInternetAccess private constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (network == null) { - return InternetResult( - false, - null, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) + return InternetResult(false, null, attempted.toList(), SystemClock.elapsedRealtime() - started) } } else if (!isConnected(context)) { - return InternetResult( - false, - null, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) + return InternetResult(false, null, attempted.toList(), SystemClock.elapsedRealtime() - started) } val probeExecutor = newProbeExecutor() @@ -1122,93 +1294,51 @@ class ConnectivityAndInternetAccess private constructor( val normalizedResolvers = normalizeDnsResolvers(dnsResolvers) var reached: String? - /* - * Prefer the DNS configuration of the selected Android Network before - * contacting public resolvers directly. This respects the effective - * network path (including VPN and Private DNS on modern Android). - * - * An empty resolver list still disables the entire DNS stage, preserving - * the historical Builder semantics and strict captive-portal mode. - * A custom DnsProbeStrategy also owns the DNS stage completely, so the - * built-in effective-DNS preflight is only used with DefaultDnsProbe. - */ - if (normalizedResolvers.isNotEmpty() && - dnsStrategy is DefaultDnsProbe - ) { + if (normalizedResolvers.isNotEmpty() && dnsStrategy is DefaultDnsProbe) { reached = raceProbes( - listOf( - ProbeAttempt(effectiveDnsLabel()) { - checkEffectiveDns(network) - } - ), + listOf(ProbeAttempt(effectiveDnsLabel()) { checkEffectiveDns(network) }), attempted, - minOf( - deadline, - started + EFFECTIVE_DNS_STAGE_TIMEOUT_MS - ), + minOf(deadline, started + EFFECTIVE_DNS_STAGE_TIMEOUT_MS), probeExecutor ) - - if (reached != null) { - return InternetResult( - true, - reached, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) - } + if (reached != null) return InternetResult(true, reached, attempted.toList(), SystemClock.elapsedRealtime() - started) } - val dnsAttempts = normalizedResolvers.map { resolver -> - ProbeAttempt(dnsEndpointLabel(resolver)) { + val transportAttempts = mutableListOf() + normalizeDnsResolvers(dnsResolvers).forEach { resolver -> + transportAttempts += ProbeAttempt(dnsEndpointLabel(resolver)) { dnsStrategy.checkDns(resolver, network) } } - - reached = raceProbes( - dnsAttempts, - attempted, - minOf(deadline, started + DNS_STAGE_TIMEOUT_MS), - probeExecutor - ) - - if (reached != null) { - return InternetResult( - true, - reached, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) + normalizeEndpointTargets(tcpTargets, HTTPS_PORT, "tcpTargets").forEach { target -> + val endpoint = parseEndpoint(target, HTTPS_PORT) + transportAttempts += ProbeAttempt(endpointLabel("tcp", endpoint)) { + tcpStrategy.checkTcp(endpoint.host, endpoint.port, network) + } } - - val hostAttempts = normalizeHosts(hosts).map { host -> - ProbeAttempt(host) { - httpStrategy.checkHttp(host, network) + normalizeNtpTargets(ntpTargets).forEach { host -> + transportAttempts += ProbeAttempt("ntp://${formatHost(host)}:$NTP_PORT") { + ntpStrategy.checkNtp(host, network) } } - reached = raceProbes( - hostAttempts, - attempted, - deadline, - probeExecutor - ) + reached = raceProbes(transportAttempts, attempted, minOf(deadline, started + DNS_STAGE_TIMEOUT_MS), probeExecutor) + if (reached != null) return InternetResult(true, reached, attempted.toList(), SystemClock.elapsedRealtime() - started) - if (reached != null) { - return InternetResult( - true, - reached, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) + val applicationAttempts = mutableListOf() + normalizeHosts(hosts).forEach { host -> + applicationAttempts += ProbeAttempt(host) { httpStrategy.checkHttp(host, network) } + } + normalizeEndpointTargets(tlsTargets, HTTPS_PORT, "tlsTargets").forEach { target -> + val endpoint = parseEndpoint(target, HTTPS_PORT) + applicationAttempts += ProbeAttempt(endpointLabel("tls", endpoint)) { + tlsStrategy.checkTls(endpoint.host, endpoint.port, network) + } } - return InternetResult( - false, - null, - attempted.toList(), - SystemClock.elapsedRealtime() - started - ) + reached = raceProbes(applicationAttempts, attempted, deadline, probeExecutor) + if (reached != null) return InternetResult(true, reached, attempted.toList(), SystemClock.elapsedRealtime() - started) + return InternetResult(false, null, attempted.toList(), SystemClock.elapsedRealtime() - started) } finally { probeExecutor.shutdownNow() } @@ -1288,9 +1418,100 @@ class ConnectivityAndInternetAccess private constructor( private fun effectiveDnsLabel(): String = "dns://system/$DNS_QUERY_NAME" + private fun resolveAddress(host: String, network: Network?): InetAddress { + val addresses = if (network != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + network.getAllByName(host) + } else { + InetAddress.getAllByName(host) + } + if (addresses.isEmpty()) { + throw IOException("No address for $host") + } + return addresses[0] + } + + class DefaultTcpProbe : TcpProbeStrategy { + override fun checkTcp(host: String, port: Int, network: Network?): Boolean { + var socket: Socket? = null + return try { + socket = Socket() + if (network != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + network.bindSocket(socket) + } + socket.connect(InetSocketAddress(resolveAddress(host, network), port), CONNECT_TIMEOUT_MS) + true + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + try { socket?.close() } catch (_: IOException) { } + } + } + } + + class DefaultNtpProbe : NtpProbeStrategy { + override fun checkNtp(host: String, network: Network?): Boolean { + var socket: DatagramSocket? = null + return try { + socket = DatagramSocket() + if (network != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + network.bindSocket(socket) + } + socket.soTimeout = DNS_TIMEOUT_MS + val address = resolveAddress(host, network) + val request = ByteArray(48) + request[0] = 0x1B + socket.send(DatagramPacket(request, request.size, address, NTP_PORT)) + val response = DatagramPacket(ByteArray(48), 48) + socket.receive(response) + response.length >= 48 + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + socket?.close() + } + } + } + + class DefaultTlsProbe : TlsProbeStrategy { + override fun checkTls(host: String, port: Int, network: Network?): Boolean { + var socket: Socket? = null + var sslSocket: SSLSocket? = null + return try { + socket = Socket() + if (network != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + network.bindSocket(socket) + } + socket.connect(InetSocketAddress(resolveAddress(host, network), port), CONNECT_TIMEOUT_MS) + socket.soTimeout = READ_TIMEOUT_MS + val factory = tls12SocketFactory ?: (SSLSocketFactory.getDefault() as SSLSocketFactory) + sslSocket = factory.createSocket(socket, host, port, true) as SSLSocket + sslSocket.soTimeout = READ_TIMEOUT_MS + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && + sslSocket.supportedProtocols.contains("TLSv1.2")) { + sslSocket.enabledProtocols = arrayOf("TLSv1.2") + } + sslSocket.startHandshake() + true + } catch (_: IOException) { + false + } catch (_: RuntimeException) { + false + } finally { + try { sslSocket?.close() } catch (_: IOException) { } + if (sslSocket == null) { + try { socket?.close() } catch (_: IOException) { } + } + } + } + } + class DefaultDnsProbe : DnsProbeStrategy { override fun checkDns(resolver: String, network: Network?): Boolean { - val endpoint = parseDnsResolver(resolver) + val endpoint = parseEndpoint(resolver, DNS_PORT) var socket: DatagramSocket? = null return try { @@ -1305,7 +1526,7 @@ class ConnectivityAndInternetAccess private constructor( } socket.soTimeout = DNS_TIMEOUT_MS - socket.connect(InetSocketAddress(endpoint.host, endpoint.port)) + socket.connect(InetSocketAddress(resolveAddress(endpoint.host, network), endpoint.port)) socket.send(DatagramPacket(query, query.size)) val buffer = ByteArray(512) @@ -1591,34 +1812,6 @@ class ConnectivityAndInternetAccess private constructor( private fun NetworkInfo?.isConnectedLegacy(): Boolean = this != null && isAvailable && isConnected - private fun isLegacyConnecting( - connectivityManager: ConnectivityManager - ): Boolean = legacyNetworks(connectivityManager).any { info -> - info != null && - info.isAvailable && - info.state == NetworkInfo.State.CONNECTING - } - - private fun updateLegacyConnectingStallState( - connecting: Boolean - ): Boolean { - synchronized(connectionAttemptLock) { - if (!connecting) { - legacyConnectingSinceElapsedRealtime = -1L - return false - } - - val now = SystemClock.elapsedRealtime() - if (legacyConnectingSinceElapsedRealtime < 0L) { - legacyConnectingSinceElapsedRealtime = now - return false - } - - return now - legacyConnectingSinceElapsedRealtime >= - CONNECTION_ATTEMPT_TIMEOUT_MS - } - } - private fun isConnectionFast(type: Int, subType: Int): Boolean { if (type == ConnectivityManager.TYPE_WIFI || type == ConnectivityManager.TYPE_ETHERNET @@ -1682,9 +1875,12 @@ class ConnectivityAndInternetAccess private constructor( * rejects option-looking values and command/path punctuation while * retaining IPv4, IPv6 zone identifiers and ordinary host names. */ + val processTarget = stripAddressBrackets(value) require( !value.startsWith("-") && - value.matches(Regex("[A-Za-z0-9._:%-]+")) + !processTarget.startsWith("-") && + processTarget.isNotEmpty() && + processTarget.matches(Regex("[A-Za-z0-9._:%-]+")) ) { "Invalid ICMP target: $value" } @@ -1728,21 +1924,55 @@ class ConnectivityAndInternetAccess private constructor( private fun normalizeDnsResolvers(resolvers: List): List { val normalized = LinkedHashSet() - for (raw in resolvers) { val value = raw.trim() if (value.isNotEmpty()) { - parseDnsResolver(value) + parseEndpoint(value, DNS_PORT) normalized += value } } + return normalized.toList() + } + private fun normalizeEndpointTargets( + targets: List, + defaultPort: Int, + argumentName: String + ): List { + val normalized = LinkedHashSet() + for (raw in targets) { + val value = raw.trim() + if (value.isNotEmpty()) { + try { + parseEndpoint(value, defaultPort) + } catch (error: IllegalArgumentException) { + throw IllegalArgumentException("$argumentName: ${error.message}", error) + } + normalized += value + } + } + return normalized.toList() + } + + private fun normalizeNtpTargets(targets: List): List { + val normalized = LinkedHashSet() + for (raw in targets) { + val value = raw.trim() + if (value.isEmpty()) { + continue + } + + val endpoint = parseEndpoint(value, NTP_PORT) + require(endpoint.port == NTP_PORT) { + "NTP target must use port 123: $value" + } + normalized += endpoint.host + } return normalized.toList() } private fun isValidURL(address: String?): Boolean { address ?: throw IllegalArgumentException("url == null") - return try { val parsed = URL(address) parsed.toURI() @@ -1754,63 +1984,79 @@ class ConnectivityAndInternetAccess private constructor( } } - private fun dnsEndpointLabel(resolver: String): String { - val endpoint = parseDnsResolver(resolver) - val host = if (':' in endpoint.host) { - "[${endpoint.host}]" + private fun dnsEndpointLabel(resolver: String): String = + endpointLabel("dns", parseEndpoint(resolver, DNS_PORT)) + + private fun endpointLabel(scheme: String, endpoint: Endpoint): String = + "$scheme://${formatHost(endpoint.host)}:${endpoint.port}" + + private fun formatHost(host: String): String = + if (':' in host) "[$host]" else host + + private fun stripAddressBrackets(value: String): String { + val trimmed = value.trim() + return if (trimmed.startsWith("[") && trimmed.endsWith("]") && trimmed.length > 2) { + trimmed.substring(1, trimmed.length - 1) } else { - endpoint.host + trimmed } - return "dns://$host:${endpoint.port}" } - private fun parseDnsResolver(resolver: String): DnsResolver { - val value = resolver.trim() + private fun parseEndpoint(target: String, defaultPort: Int): Endpoint { + require(defaultPort in 1..65_535) { + "Invalid default port: $defaultPort" + } + + val value = target.trim() require(value.isNotEmpty()) { - "Invalid DNS resolver" + "Invalid endpoint" } - var host: String - var port = DNS_PORT + val host: String + var port = defaultPort if (value.startsWith("[")) { val closingBracket = value.indexOf(']') - require(closingBracket > 1) { - "Invalid DNS resolver" + require(closingBracket > 1 && value.indexOf('[', 1) < 0) { + "Invalid endpoint: $target" } host = value.substring(1, closingBracket).trim() val remainder = value.substring(closingBracket + 1).trim() if (remainder.isNotEmpty()) { - require(remainder.startsWith(":")) { - "Invalid DNS resolver" + require(remainder.startsWith(":") && remainder.indexOf(':', 1) < 0) { + "Invalid endpoint: $target" } port = parsePort(remainder.substring(1)) } } else { + require('[' !in value && ']' !in value) { + "Invalid endpoint: $target" + } + val firstColon = value.indexOf(':') val lastColon = value.lastIndexOf(':') - - if (firstColon > 0 && firstColon == lastColon) { + if (firstColon >= 0 && firstColon == lastColon) { host = value.substring(0, firstColon).trim() port = parsePort(value.substring(firstColon + 1)) } else { + // Multiple colons without brackets are a bare IPv6 literal. host = value } } - require(host.isNotEmpty() && port in 1..65_535) { - "Invalid DNS resolver" + require(host.isNotEmpty()) { + "Invalid endpoint: $target" } - return DnsResolver(host, port) + return Endpoint(host, port) } private fun parsePort(rawPort: String): Int { val port = rawPort.trim().toIntOrNull() - ?: throw IllegalArgumentException("Invalid DNS resolver port") + ?: throw IllegalArgumentException("Invalid endpoint port") require(port in 1..65_535) { - "Invalid DNS resolver port" + "Invalid endpoint port" } return port } @@ -1839,13 +2085,11 @@ class ConnectivityAndInternetAccess private constructor( } } ) - private fun timeoutConnectionAttempt(attempt: ConnectionAttempt): Boolean { synchronized(connectionAttemptLock) { if (attempt.closed) { return false } - attempt.closed = true connectionAttemptQueue.remove(attempt) connectionAttempts.updateAndGet { value -> @@ -1855,7 +2099,6 @@ class ConnectivityAndInternetAccess private constructor( return true } } - private fun expireTimedOutConnectionAttempts() { val now = SystemClock.elapsedRealtime() @@ -1883,6 +2126,34 @@ class ConnectivityAndInternetAccess private constructor( } } + private fun isLegacyConnecting( + connectivityManager: ConnectivityManager + ): Boolean = legacyNetworks(connectivityManager).any { info -> + info != null && + info.isAvailable && + info.state == NetworkInfo.State.CONNECTING + } + + private fun updateLegacyConnectingStallState( + connecting: Boolean + ): Boolean { + synchronized(connectionAttemptLock) { + if (!connecting) { + legacyConnectingSinceElapsedRealtime = -1L + return false + } + + val now = SystemClock.elapsedRealtime() + if (legacyConnectingSinceElapsedRealtime < 0L) { + legacyConnectingSinceElapsedRealtime = now + return false + } + + return now - legacyConnectingSinceElapsedRealtime >= + CONNECTION_ATTEMPT_TIMEOUT_MS + } + } + private fun clearConnectionAttempts() { synchronized(connectionAttemptLock) { connectionAttemptQueue.forEach { attempt -> @@ -1900,11 +2171,10 @@ class ConnectivityAndInternetAccess private constructor( val operation: () -> Boolean ) - private data class DnsResolver( + private data class Endpoint( val host: String, val port: Int ) - private class ConnectionAttempt( val startedAtElapsedRealtime: Long ) { From 88ed46c56e66f746aa77a5525ba38c4c4a82a3ec Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:25:22 +0200 Subject: [PATCH 5/8] feat: apply defensive adaptive connectivity policy --- PokemonJetpackCompose/app/build.gradle | 4 +- .../pokemonjetpackcompose/api/PokemonAPI.kt | 18 +++- .../ui/list/PokemonListActivity.kt | 4 +- .../ui/list/PokemonListViewModel.kt | 101 +++++++++++------- .../util/AppConnectivityManager.kt | 47 ++++---- .../util/ConnectivityAndInternetAccess.kt | 23 ++-- .../AppConnectivityManagerTest.kt | 27 +++++ README.md | 14 ++- 8 files changed, 159 insertions(+), 79 deletions(-) create mode 100644 PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt diff --git a/PokemonJetpackCompose/app/build.gradle b/PokemonJetpackCompose/app/build.gradle index 63deca6..bd10f85 100644 --- a/PokemonJetpackCompose/app/build.gradle +++ b/PokemonJetpackCompose/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.mouredev.pokemonjetpackcompose" minSdk 23 targetSdk 33 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt index 3435ce0..a700838 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt @@ -4,6 +4,7 @@ import com.mouredev.pokemonjetpackcompose.model.Pokemon import com.mouredev.pokemonjetpackcompose.model.PokemonList import retrofit2.Call import retrofit2.Callback +import retrofit2.HttpException import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -22,7 +23,10 @@ object PokemonAPI { } - fun loadPokemon(success: (pokemonList: List) -> Unit, failure: () -> Unit) { + fun loadPokemon( + success: (pokemonList: List) -> Unit, + failure: (error: Throwable) -> Unit + ) { val retrofit = Retrofit.Builder().baseUrl("https://pokeapi.co/api/v2/") .addConverterFactory(GsonConverterFactory.create()).build() @@ -31,14 +35,20 @@ object PokemonAPI { service.loadPokemon().enqueue(object: Callback { override fun onResponse(call: Call, response: Response) { - success(response.body()?.results ?: listOf()) + if (response.isSuccessful) { + success(response.body()?.results ?: listOf()) + } else { + // HTTP errors prove that the server responded; do not run a generic + // connectivity diagnosis for them. + failure(HttpException(response)) + } } override fun onFailure(call: Call, t: Throwable) { - failure() + failure(t) } }) } -} \ No newline at end of file +} diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt index 8017ba3..8ddd01f 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt @@ -123,7 +123,7 @@ fun PokemonList(viewModel: PokemonListViewModel) { ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Orden: endpoints de la app -> DNS/TCP/NTP/TLS/HTTPS -> fallback general e ICMP opcional.", + text = "La petición real mantiene sus timeouts; el diagnóstico general (DNS/TCP/NTP/TLS/HTTPS/ICMP) solo se ejecuta tras un fallo de red o al solicitarlo.", fontSize = 11.sp, color = Color.Gray ) @@ -152,7 +152,7 @@ fun PokemonList(viewModel: PokemonListViewModel) { Spacer(modifier = Modifier.height(8.dp)) Text("Error al cargar la lista de Pokémon.") Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadData() }) { + Button(onClick = { viewModel.loadData(context) }) { Text("Reintentar") } } diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt index 7a6b122..262286d 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt @@ -9,6 +9,7 @@ import com.mouredev.pokemonjetpackcompose.api.PokemonAPI import com.mouredev.pokemonjetpackcompose.model.Pokemon import com.mouredev.pokemonjetpackcompose.util.AppConnectivityManager import com.mouredev.pokemonjetpackcompose.util.ConnectivityAndInternetAccess +import retrofit2.HttpException /** * Created by MoureDev by Brais Moure on 28/10/22. @@ -32,22 +33,18 @@ class PokemonListViewModel : ViewModel() { private var networkObserver: ConnectivityAndInternetAccess.NetworkObserver? = null private var activeRequest: ConnectivityAndInternetAccess.Request? = null - init { - loadData() - } - fun startObservingNetwork(context: Context) { if (networkObserver != null) return networkObserver = ConnectivityAndInternetAccess.observeNetwork(context) { state -> networkState = state if (state.connected) { - // Perform active check with app endpoints first when connected - performConnectivityCheck(context) + diagnosticSummary = "Red disponible. La app realizará la petición real con sus propios timeouts." + if (pokemonList.isEmpty() && !isLoadingData) { + loadData(context) + } } else { - isAppBackendReachable = false - isFallbackInternetReachable = false - diagnosticSummary = "Sin conexión de red." + markOffline() } } } @@ -59,55 +56,85 @@ class PokemonListViewModel : ViewModel() { activeRequest = null } - fun performConnectivityCheck(context: Context, runExtremeFallbackAlways: Boolean = false) { + fun performConnectivityCheck(context: Context) { + if (!AppConnectivityManager.isConnected(context)) { + markOffline() + return + } + isCheckingConnectivity = true activeRequest?.cancel() - - // Tier 1: Check primary app endpoints first - activeRequest = AppConnectivityManager.checkAppEndpointsAsync(context) { appResult -> - if (appResult.reachable) { - isAppBackendReachable = true - isFallbackInternetReachable = true - isCheckingConnectivity = false - diagnosticSummary = "Conectado al servidor de la app via: ${appResult.reachedHost} (${appResult.elapsedMilliseconds} ms)" - if (pokemonList.isEmpty() || errorLoadingData) { - loadData() - } + diagnosticSummary = "Ejecutando diagnóstico general de Internet..." + activeRequest = AppConnectivityManager.diagnoseGeneralInternetAsync(context) { result -> + isCheckingConnectivity = false + isFallbackInternetReachable = result.reachable + diagnosticSummary = if (result.reachable) { + "Internet general disponible vía ${result.reachedHost}. El fallo puede ser específico de PokéAPI." } else { - isAppBackendReachable = false - diagnosticSummary = "Endpoint de la app inalcanzable. Iniciando diagnóstico extremo (DNS público/dominios por defecto)..." - - // Tier 2 (Extreme cases only): Fallback to public DNS & default domains - activeRequest = AppConnectivityManager.checkExtremeFallbackAsync(context) { fallbackResult -> - isCheckingConnectivity = false - if (fallbackResult.reachable) { - isFallbackInternetReachable = true - diagnosticSummary = "Internet general disponible via ${fallbackResult.reachedHost}, pero los servidores de Pokémon están caídos o inaccesibles." - } else { - isFallbackInternetReachable = false - diagnosticSummary = "Sin acceso a Internet. Falló el diagnóstico DNS y dominios por defecto (${fallbackResult.attemptedHosts.joinToString()})." - } - } + "Sin acceso general a Internet (${result.attemptedHosts.joinToString()})." } } } - fun loadData() { + fun loadData(context: Context) { + if (!AppConnectivityManager.isConnected(context)) { + markOffline() + return + } + isLoadingData = true errorLoadingData = false + isAppBackendReachable = null PokemonAPI.loadPokemon({ pokemon -> + isAppBackendReachable = true + isFallbackInternetReachable = true pokemonList = pokemon isLoadingData = false errorLoadingData = false + diagnosticSummary = "PokéAPI disponible (${pokemon.size} Pokémon cargados)." }, { + error -> isLoadingData = false errorLoadingData = true + isAppBackendReachable = false + if (error !is HttpException && AppConnectivityManager.isNetworkFailure(error)) { + diagnosticSummary = "Falló la conexión con PokéAPI. Ejecutando diagnóstico general..." + runGeneralDiagnosis(context) + } else { + diagnosticSummary = "PokéAPI respondió con un error (${(error as? HttpException)?.code() ?: "desconocido"})." + } }) } + private fun runGeneralDiagnosis(context: Context) { + if (!AppConnectivityManager.isConnected(context)) { + markOffline() + return + } + isCheckingConnectivity = true + activeRequest?.cancel() + activeRequest = AppConnectivityManager.diagnoseGeneralInternetAsync(context) { result -> + isCheckingConnectivity = false + isFallbackInternetReachable = result.reachable + diagnosticSummary = if (result.reachable) { + "Internet general disponible, pero PokéAPI no responde." + } else { + "Sin acceso general a Internet." + } + } + } + + private fun markOffline() { + isAppBackendReachable = false + isFallbackInternetReachable = false + isCheckingConnectivity = false + isLoadingData = false + diagnosticSummary = "Sin conexión de red. La operación se ha pospuesto." + } + override fun onCleared() { stopObservingNetwork() super.onCleared() } -} \ No newline at end of file +} diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt index aa7100f..a9b5b94 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt @@ -3,42 +3,43 @@ package com.mouredev.pokemonjetpackcompose.util import android.content.Context /** - * Manages tiered connectivity checks for the Pokemon app. + * Shared connectivity policy for the Pokemon app. * - * Tier 1 (Primary): Probes the app's backend domains (pokeapi.co, raw.githubusercontent.com). - * Tier 2 (Extreme / Diagnostic Fallback): Probes public DNS resolvers and default global hosts - * to determine whether an issue is specific to the PokeAPI backend or a general Internet outage. + * The normal request path is deliberately cheap: callers first use [isConnected] and then + * perform the real operation with its own timeout/error handling. The active diagnostic is + * reserved for transport failures or an explicit user request. */ object AppConnectivityManager { - val APP_ENDPOINTS = listOf( - "https://pokeapi.co/api/v2/pokemon", - "https://pokeapi.co/", - "https://raw.githubusercontent.com/" - ) - - private val appConnectivity = ConnectivityAndInternetAccess.Builder() - .setHosts(APP_ENDPOINTS) - .setDnsResolvers(emptyList()) // DNS phase disabled for pure app HTTP/HTTPS reachability - .build() + private val generalConnectivity by lazy { + ConnectivityAndInternetAccess.Builder().build() + } - private val fallbackConnectivity = ConnectivityAndInternetAccess.Builder().build() + fun isConnected(context: Context): Boolean = + ConnectivityAndInternetAccess.isConnected(context) - fun checkAppEndpointsAsync( + fun diagnoseGeneralInternetAsync( context: Context, onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit ): ConnectivityAndInternetAccess.Request { - return appConnectivity.checkInternetAsync(context) { result -> + return generalConnectivity.checkInternetAsync(context) { result -> onResult(result) } } - fun checkExtremeFallbackAsync( - context: Context, - onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit - ): ConnectivityAndInternetAccess.Request { - return fallbackConnectivity.checkInternetAsync(context) { result -> - onResult(result) + fun isNetworkFailure(error: Throwable): Boolean { + var current: Throwable? = error + while (current != null) { + if (current is java.net.UnknownHostException || + current is java.net.ConnectException || + current is java.net.SocketTimeoutException || + current is javax.net.ssl.SSLException || + current is java.io.IOException + ) { + return true + } + current = current.cause } + return false } } diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt index c34e898..e4a0667 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -638,9 +638,7 @@ class ConnectivityAndInternetAccess private constructor( val attempt = connectionAttemptQueue.removeFirst() if (!attempt.closed) { attempt.closed = true - connectionAttempts.updateAndGet { value -> - if (value > 0) value - 1 else 0 - } + decrementConnectionAttempts() return } } @@ -2092,9 +2090,7 @@ class ConnectivityAndInternetAccess private constructor( } attempt.closed = true connectionAttemptQueue.remove(attempt) - connectionAttempts.updateAndGet { value -> - if (value > 0) value - 1 else 0 - } + decrementConnectionAttempts() connectionAttemptStalled.set(true) return true } @@ -2118,9 +2114,7 @@ class ConnectivityAndInternetAccess private constructor( attempt.closed = true connectionAttemptQueue.removeFirst() - connectionAttempts.updateAndGet { value -> - if (value > 0) value - 1 else 0 - } + decrementConnectionAttempts() connectionAttemptStalled.set(true) } } @@ -2166,6 +2160,16 @@ class ConnectivityAndInternetAccess private constructor( } } + /** Atomic decrement compatible with the app's API 23 minimum. */ + private fun decrementConnectionAttempts() { + while (true) { + val current = connectionAttempts.get() + if (current <= 0 || connectionAttempts.compareAndSet(current, current - 1)) { + return + } + } + } + private data class ProbeAttempt( val label: String, val operation: () -> Boolean @@ -2247,4 +2251,3 @@ class ConnectivityAndInternetAccess private constructor( } } } - diff --git a/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt new file mode 100644 index 0000000..9bf5050 --- /dev/null +++ b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt @@ -0,0 +1,27 @@ +package com.mouredev.pokemonjetpackcompose + +import com.mouredev.pokemonjetpackcompose.util.AppConnectivityManager +import java.net.UnknownHostException +import java.net.SocketTimeoutException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppConnectivityManagerTest { + + @Test + fun networkFailuresAreDiagnosed() { + assertTrue(AppConnectivityManager.isNetworkFailure(UnknownHostException("pokeapi.co"))) + assertTrue(AppConnectivityManager.isNetworkFailure(SocketTimeoutException("timeout"))) + assertTrue( + AppConnectivityManager.isNetworkFailure( + IllegalStateException("wrapper", UnknownHostException("dns")) + ) + ) + } + + @Test + fun nonNetworkFailuresAreNotDiagnosed() { + assertFalse(AppConnectivityManager.isNetworkFailure(IllegalArgumentException("bad data"))) + } +} diff --git a/README.md b/README.md index 4aaf367..f2da4e5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ * [Retrofit](https://github.com/square/retrofit) * [PokéAPI](https://pokeapi.co) +### Política de conectividad + +Antes de iniciar una petición, la aplicación consulta únicamente el estado local de la red +mediante `ConnectivityAndInternetAccess.isConnected(...)`. Si no hay una red utilizable, la +petición se pospone y la interfaz muestra el estado offline. Cuando existe conectividad, se +ejecuta la petición real de PokéAPI con sus propios timeouts y manejo de errores, sin sondeos +activos redundantes ni enumeración de hosts. + +El diagnóstico activo general (DNS, TCP, NTP, TLS, HTTPS e ICMP opcional) solo se ejecuta tras +un fallo compatible con transporte/red o cuando el usuario pulsa “Diagnosticar”. Las respuestas +HTTP 4xx/5xx se tratan como errores del servicio y no disparan ese diagnóstico. + También tienes versión de esta aplicación en iOS @@ -40,4 +52,4 @@ Soy ingeniero de software desde hace más de 12 años. Desde hace 4 años combin ### En mi perfil de GitHub tienes más información -[![Web](https://img.shields.io/badge/GitHub-MoureDev-14a1f0?style=for-the-badge&logo=github&logoColor=white&labelColor=101010)](https://github.com/mouredev) \ No newline at end of file +[![Web](https://img.shields.io/badge/GitHub-MoureDev-14a1f0?style=for-the-badge&logo=github&logoColor=white&labelColor=101010)](https://github.com/mouredev) From 39711887c3971cd4c5f9884e5ba5043e010bff67 Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:02:57 +0200 Subject: [PATCH 6/8] Update connectivity handling for physical networks --- .../ui/list/PokemonListActivity.kt | 2 +- .../ui/list/PokemonListViewModel.kt | 8 ++--- .../util/AppConnectivityManager.kt | 11 +++++++ .../util/ConnectivityAndInternetAccess.kt | 33 ++++++++++++++++++- .../AppConnectivityManagerTest.kt | 8 +++++ 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt index 8ddd01f..afc2862 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt @@ -179,7 +179,7 @@ fun ConnectivityBanner( val isFallbackReachable = viewModel.isFallbackInternetReachable val isChecking = viewModel.isCheckingConnectivity - val isConnected = state?.connected == true + val isConnected = state?.connected == true && state.physicalNetworkAvailable if (isConnected && isAppReachable != false) { // Connected & App Backend is healthy or checking diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt index 262286d..b97eaf7 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt @@ -38,7 +38,7 @@ class PokemonListViewModel : ViewModel() { networkObserver = ConnectivityAndInternetAccess.observeNetwork(context) { state -> networkState = state - if (state.connected) { + if (state.connected && state.physicalNetworkAvailable) { diagnosticSummary = "Red disponible. La app realizará la petición real con sus propios timeouts." if (pokemonList.isEmpty() && !isLoadingData) { loadData(context) @@ -57,7 +57,7 @@ class PokemonListViewModel : ViewModel() { } fun performConnectivityCheck(context: Context) { - if (!AppConnectivityManager.isConnected(context)) { + if (!AppConnectivityManager.canStartRemoteRequest(context)) { markOffline() return } @@ -77,7 +77,7 @@ class PokemonListViewModel : ViewModel() { } fun loadData(context: Context) { - if (!AppConnectivityManager.isConnected(context)) { + if (!AppConnectivityManager.canStartRemoteRequest(context)) { markOffline() return } @@ -108,7 +108,7 @@ class PokemonListViewModel : ViewModel() { } private fun runGeneralDiagnosis(context: Context) { - if (!AppConnectivityManager.isConnected(context)) { + if (!AppConnectivityManager.canStartRemoteRequest(context)) { markOffline() return } diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt index a9b5b94..4bfeea7 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt @@ -18,6 +18,17 @@ object AppConnectivityManager { fun isConnected(context: Context): Boolean = ConnectivityAndInternetAccess.isConnected(context) + fun hasPhysicalNetwork(context: Context): Boolean = + ConnectivityAndInternetAccess.hasPhysicalNetwork(context) + + fun canStartRemoteRequest(context: Context): Boolean = + canStartRemoteRequest(isConnected(context), hasPhysicalNetwork(context)) + + internal fun canStartRemoteRequest( + isConnected: Boolean, + hasPhysicalNetwork: Boolean + ): Boolean = isConnected && hasPhysicalNetwork + fun diagnoseGeneralInternetAsync( context: Context, onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt index e4a0667..4be4900 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -172,12 +172,14 @@ class ConnectivityAndInternetAccess private constructor( /** Cheap passive state of the application's default network. */ data class NetworkState internal constructor( val connected: Boolean, + val physicalNetworkAvailable: Boolean, val internetValidated: Boolean, val captivePortalDetected: Boolean, val observedAtElapsedRealtime: Long ) { internal fun sameConnectivityState(other: NetworkState): Boolean = connected == other.connected && + physicalNetworkAvailable == other.physicalNetworkAvailable && internetValidated == other.internetValidated && captivePortalDetected == other.captivePortalDetected } @@ -227,7 +229,7 @@ class ConnectivityAndInternetAccess private constructor( networkCapabilities: NetworkCapabilities ) { currentDefaultNetwork = network - publish(networkStateFromCapabilities(networkCapabilities)) + publish(networkStateFromCapabilities(applicationContext, networkCapabilities)) } override fun onLost(network: Network) { @@ -701,6 +703,30 @@ class ConnectivityAndInternetAccess private constructor( return connected } + /** + * Cheap passive guard that ignores a dangling VPN-only default network. + * A VPN capability can remain present after its underlying Wi-Fi/mobile + * transport disappeared, so it must not make the app appear connected. + */ + @JvmStatic + fun hasPhysicalNetwork(context: Context?): Boolean { + context ?: throw IllegalArgumentException("context == null") + val connectivityManager = manager(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return connectivityManager.allNetworks.any { network -> + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities.isUsable() && ( + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true || + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true || + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true + ) + } + } + + return connectivityManager.activeNetworkInfo.isConnectedLegacy() + } + /** Cheap point-in-time snapshot of the application's default network. */ @JvmStatic fun snapshotNetworkState(context: Context): NetworkState { @@ -710,6 +736,7 @@ class ConnectivityAndInternetAccess private constructor( val active = connectivityManager.activeNetwork ?: return disconnectedNetworkState() return networkStateFromCapabilities( + context, connectivityManager.getNetworkCapabilities(active) ) } @@ -717,6 +744,7 @@ class ConnectivityAndInternetAccess private constructor( val connected = connectivityManager.activeNetworkInfo.isConnectedLegacy() return NetworkState( connected = connected, + physicalNetworkAvailable = connected, internetValidated = false, captivePortalDetected = false, observedAtElapsedRealtime = SystemClock.elapsedRealtime() @@ -1704,6 +1732,7 @@ class ConnectivityAndInternetAccess private constructor( } private fun networkStateFromCapabilities( + context: Context, capabilities: NetworkCapabilities? ): NetworkState { val connected = capabilities.isUsable() @@ -1718,6 +1747,7 @@ class ConnectivityAndInternetAccess private constructor( ) == true return NetworkState( connected = connected, + physicalNetworkAvailable = hasPhysicalNetwork(context), internetValidated = validated, captivePortalDetected = captivePortal, observedAtElapsedRealtime = SystemClock.elapsedRealtime() @@ -1726,6 +1756,7 @@ class ConnectivityAndInternetAccess private constructor( private fun disconnectedNetworkState(): NetworkState = NetworkState( connected = false, + physicalNetworkAvailable = false, internetValidated = false, captivePortalDetected = false, observedAtElapsedRealtime = SystemClock.elapsedRealtime() diff --git a/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt index 9bf5050..e1f24ae 100644 --- a/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt +++ b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt @@ -24,4 +24,12 @@ class AppConnectivityManagerTest { fun nonNetworkFailuresAreNotDiagnosed() { assertFalse(AppConnectivityManager.isNetworkFailure(IllegalArgumentException("bad data"))) } + + @Test + fun remoteRequestsRequireAConnectedPhysicalNetwork() { + assertTrue(AppConnectivityManager.canStartRemoteRequest(true, true)) + assertFalse(AppConnectivityManager.canStartRemoteRequest(true, false)) + assertFalse(AppConnectivityManager.canStartRemoteRequest(false, true)) + assertFalse(AppConnectivityManager.canStartRemoteRequest(false, false)) + } } From cf27a2f47d10e0986c90e01775922af8d19f48ce Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:11:15 +0200 Subject: [PATCH 7/8] feat: handle physical network state in release --- PokemonJetpackCompose/app/build.gradle | 4 ++-- README.md | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/PokemonJetpackCompose/app/build.gradle b/PokemonJetpackCompose/app/build.gradle index bd10f85..9483fe0 100644 --- a/PokemonJetpackCompose/app/build.gradle +++ b/PokemonJetpackCompose/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.mouredev.pokemonjetpackcompose" minSdk 23 targetSdk 33 - versionCode 2 - versionName "1.1" + versionCode 3 + versionName "1.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/README.md b/README.md index f2da4e5..94e279d 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ ### Política de conectividad -Antes de iniciar una petición, la aplicación consulta únicamente el estado local de la red -mediante `ConnectivityAndInternetAccess.isConnected(...)`. Si no hay una red utilizable, la +Antes de iniciar una petición, la aplicación consulta el estado local de la red mediante +`ConnectivityAndInternetAccess.isConnected(...)` y `ConnectivityAndInternetAccess.hasPhysicalNetwork(...)`. +La segunda comprobación evita que una VPN local (por ejemplo, AdGuard) se considere conectividad +suficiente cuando no queda Wi-Fi, datos móviles ni Ethernet. Si no hay una red utilizable, la petición se pospone y la interfaz muestra el estado offline. Cuando existe conectividad, se ejecuta la petición real de PokéAPI con sus propios timeouts y manejo de errores, sin sondeos activos redundantes ni enumeración de hosts. @@ -22,6 +24,12 @@ El diagnóstico activo general (DNS, TCP, NTP, TLS, HTTPS e ICMP opcional) solo un fallo compatible con transporte/red o cuando el usuario pulsa “Diagnosticar”. Las respuestas HTTP 4xx/5xx se tratan como errores del servicio y no disparan ese diagnóstico. +La UI conserva un `NetworkObserver` pasivo durante su lifecycle y reacciona a la pérdida o +recuperación del transporte físico subyacente. En el caso VPN-only (VPN activa, pero Wi-Fi/datos/ +Ethernet apagados), `isConnected()` puede seguir siendo verdadero, pero `hasPhysicalNetwork()` es +falso y no se inicia la llamada remota. `NET_CAPABILITY_VALIDATED` y `CAPTIVE_PORTAL` se mantienen +como señales independientes y no sustituyen la comprobación física. + También tienes versión de esta aplicación en iOS From e0ec60105c7dd05bb2a6ad96ed83eb0da74278a7 Mon Sep 17 00:00:00 2001 From: Rodrigo Sambade <43375046+rodrigosambadesaa@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:34:59 +0200 Subject: [PATCH 8/8] Update connectivity integration to latest gist --- PokemonJetpackCompose/app/build.gradle | 4 +- .../pokemonjetpackcompose/api/PokemonAPI.kt | 6 +- .../ui/list/PokemonListActivity.kt | 10 +- .../ui/list/PokemonListViewModel.kt | 38 +++++- .../util/AppConnectivityManager.kt | 10 +- .../util/ConnectivityAndInternetAccess.kt | 124 +++++++++++------- .../app/src/main/res/values/strings.xml | 7 +- .../AppConnectivityManagerTest.kt | 6 +- README.md | 20 ++- 9 files changed, 145 insertions(+), 80 deletions(-) diff --git a/PokemonJetpackCompose/app/build.gradle b/PokemonJetpackCompose/app/build.gradle index 9483fe0..f06c108 100644 --- a/PokemonJetpackCompose/app/build.gradle +++ b/PokemonJetpackCompose/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.mouredev.pokemonjetpackcompose" minSdk 23 targetSdk 33 - versionCode 3 - versionName "1.2" + versionCode 4 + versionName "1.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt index a700838..51ff996 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/api/PokemonAPI.kt @@ -26,13 +26,14 @@ object PokemonAPI { fun loadPokemon( success: (pokemonList: List) -> Unit, failure: (error: Throwable) -> Unit - ) { + ): Call { val retrofit = Retrofit.Builder().baseUrl("https://pokeapi.co/api/v2/") .addConverterFactory(GsonConverterFactory.create()).build() val service = retrofit.create(PokemonAPI::class.java) - service.loadPokemon().enqueue(object: Callback { + val call = service.loadPokemon() + call.enqueue(object: Callback { override fun onResponse(call: Call, response: Response) { if (response.isSuccessful) { @@ -49,6 +50,7 @@ object PokemonAPI { } }) + return call } } diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt index afc2862..214f27f 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListActivity.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import android.widget.Toast import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,6 +66,13 @@ fun PokemonList(viewModel: PokemonListViewModel) { val context = LocalContext.current var showDiagnosticDetails by remember { mutableStateOf(false) } + LaunchedEffect(viewModel.toastMessage) { + viewModel.toastMessage?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + viewModel.consumeToast() + } + } + Scaffold( topBar = { TopAppBar( @@ -179,7 +187,7 @@ fun ConnectivityBanner( val isFallbackReachable = viewModel.isFallbackInternetReachable val isChecking = viewModel.isCheckingConnectivity - val isConnected = state?.connected == true && state.physicalNetworkAvailable + val isConnected = state?.connected == true if (isConnected && isAppReachable != false) { // Connected & App Backend is healthy or checking diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt index b97eaf7..bcf558d 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/ui/list/PokemonListViewModel.kt @@ -1,15 +1,18 @@ package com.mouredev.pokemonjetpackcompose.ui.list import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.mouredev.pokemonjetpackcompose.api.PokemonAPI import com.mouredev.pokemonjetpackcompose.model.Pokemon +import com.mouredev.pokemonjetpackcompose.R import com.mouredev.pokemonjetpackcompose.util.AppConnectivityManager import com.mouredev.pokemonjetpackcompose.util.ConnectivityAndInternetAccess import retrofit2.HttpException +import retrofit2.Call /** * Created by MoureDev by Brais Moure on 28/10/22. @@ -29,21 +32,32 @@ class PokemonListViewModel : ViewModel() { var isFallbackInternetReachable: Boolean? by mutableStateOf(null) var isCheckingConnectivity: Boolean by mutableStateOf(false) var diagnosticSummary: String by mutableStateOf("") + var toastMessage: String? by mutableStateOf(null) private var networkObserver: ConnectivityAndInternetAccess.NetworkObserver? = null private var activeRequest: ConnectivityAndInternetAccess.Request? = null + private var activeApiCall: Call<*>? = null + private var lastToastMessage: String? = null + private var wasConnected: Boolean? = null fun startObservingNetwork(context: Context) { if (networkObserver != null) return networkObserver = ConnectivityAndInternetAccess.observeNetwork(context) { state -> networkState = state - if (state.connected && state.physicalNetworkAvailable) { + if (state.connected) { + if (wasConnected == false) emitToast(context, R.string.connection_recovered) + if (state.captivePortalDetected) { + emitToast(context, R.string.captive_portal) + } + wasConnected = true diagnosticSummary = "Red disponible. La app realizará la petición real con sus propios timeouts." if (pokemonList.isEmpty() && !isLoadingData) { loadData(context) } } else { + if (wasConnected != false) emitToast(context, R.string.no_network) + wasConnected = false markOffline() } } @@ -54,10 +68,13 @@ class PokemonListViewModel : ViewModel() { networkObserver = null activeRequest?.cancel() activeRequest = null + activeApiCall?.cancel() + activeApiCall = null } fun performConnectivityCheck(context: Context) { if (!AppConnectivityManager.canStartRemoteRequest(context)) { + emitToast(context, R.string.no_network) markOffline() return } @@ -86,7 +103,8 @@ class PokemonListViewModel : ViewModel() { errorLoadingData = false isAppBackendReachable = null - PokemonAPI.loadPokemon({ pokemon -> + activeApiCall = PokemonAPI.loadPokemon({ pokemon -> + activeApiCall = null isAppBackendReachable = true isFallbackInternetReachable = true pokemonList = pokemon @@ -95,6 +113,7 @@ class PokemonListViewModel : ViewModel() { diagnosticSummary = "PokéAPI disponible (${pokemon.size} Pokémon cargados)." }, { error -> + activeApiCall = null isLoadingData = false errorLoadingData = true isAppBackendReachable = false @@ -102,6 +121,7 @@ class PokemonListViewModel : ViewModel() { diagnosticSummary = "Falló la conexión con PokéAPI. Ejecutando diagnóstico general..." runGeneralDiagnosis(context) } else { + emitToast(context, R.string.service_unavailable) diagnosticSummary = "PokéAPI respondió con un error (${(error as? HttpException)?.code() ?: "desconocido"})." } }) @@ -118,8 +138,10 @@ class PokemonListViewModel : ViewModel() { isCheckingConnectivity = false isFallbackInternetReachable = result.reachable diagnosticSummary = if (result.reachable) { + emitToast(context, R.string.service_unavailable) "Internet general disponible, pero PokéAPI no responde." } else { + emitToast(context, R.string.no_internet) "Sin acceso general a Internet." } } @@ -133,6 +155,18 @@ class PokemonListViewModel : ViewModel() { diagnosticSummary = "Sin conexión de red. La operación se ha pospuesto." } + private fun emitToast(context: Context, @StringRes messageRes: Int) { + val message = context.getString(messageRes) + if (message != lastToastMessage) { + lastToastMessage = message + toastMessage = message + } + } + + fun consumeToast() { + toastMessage = null + } + override fun onCleared() { stopObservingNetwork() super.onCleared() diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt index 4bfeea7..b0fcc2c 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt @@ -18,16 +18,10 @@ object AppConnectivityManager { fun isConnected(context: Context): Boolean = ConnectivityAndInternetAccess.isConnected(context) - fun hasPhysicalNetwork(context: Context): Boolean = - ConnectivityAndInternetAccess.hasPhysicalNetwork(context) - fun canStartRemoteRequest(context: Context): Boolean = - canStartRemoteRequest(isConnected(context), hasPhysicalNetwork(context)) + isConnected(context) - internal fun canStartRemoteRequest( - isConnected: Boolean, - hasPhysicalNetwork: Boolean - ): Boolean = isConnected && hasPhysicalNetwork + internal fun canStartRemoteRequest(isConnected: Boolean): Boolean = isConnected fun diagnoseGeneralInternetAsync( context: Context, diff --git a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt index 4be4900..c97b4c9 100644 --- a/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt +++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt @@ -172,14 +172,12 @@ class ConnectivityAndInternetAccess private constructor( /** Cheap passive state of the application's default network. */ data class NetworkState internal constructor( val connected: Boolean, - val physicalNetworkAvailable: Boolean, val internetValidated: Boolean, val captivePortalDetected: Boolean, val observedAtElapsedRealtime: Long ) { internal fun sameConnectivityState(other: NetworkState): Boolean = connected == other.connected && - physicalNetworkAvailable == other.physicalNetworkAvailable && internetValidated == other.internetValidated && captivePortalDetected == other.captivePortalDetected } @@ -229,7 +227,8 @@ class ConnectivityAndInternetAccess private constructor( networkCapabilities: NetworkCapabilities ) { currentDefaultNetwork = network - publish(networkStateFromCapabilities(applicationContext, networkCapabilities)) + publish(networkStateFromCapabilities( + connectivityManager, networkCapabilities)) } override fun onLost(network: Network) { @@ -555,7 +554,11 @@ class ConnectivityAndInternetAccess private constructor( if (network == null) { return false } - return manager(context).getNetworkCapabilities(network).isUsable() + val connectivityManager = manager(context) + return isEffectivelyUsable( + connectivityManager, + connectivityManager.getNetworkCapabilities(network) + ) } @JvmStatic @@ -640,7 +643,9 @@ class ConnectivityAndInternetAccess private constructor( val attempt = connectionAttemptQueue.removeFirst() if (!attempt.closed) { attempt.closed = true - decrementConnectionAttempts() + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } return } } @@ -677,7 +682,10 @@ class ConnectivityAndInternetAccess private constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val active = connectivityManager.activeNetwork if (active != null && - connectivityManager.getNetworkCapabilities(active).isUsable() + isEffectivelyUsable( + connectivityManager, + connectivityManager.getNetworkCapabilities(active) + ) ) { clearConnectionAttempts() return true @@ -687,7 +695,10 @@ class ConnectivityAndInternetAccess private constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (connectivityManager.allNetworks.any { network -> - connectivityManager.getNetworkCapabilities(network).isUsable() + isEffectivelyUsable( + connectivityManager, + connectivityManager.getNetworkCapabilities(network) + ) } ) { clearConnectionAttempts() @@ -703,28 +714,17 @@ class ConnectivityAndInternetAccess private constructor( return connected } - /** - * Cheap passive guard that ignores a dangling VPN-only default network. - * A VPN capability can remain present after its underlying Wi-Fi/mobile - * transport disappeared, so it must not make the app appear connected. - */ + /** Returns whether a usable non-VPN network exists beneath the active path. */ @JvmStatic - fun hasPhysicalNetwork(context: Context?): Boolean { + fun hasUnderlyingNetwork(context: Context?): Boolean { context ?: throw IllegalArgumentException("context == null") - val connectivityManager = manager(context) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - return connectivityManager.allNetworks.any { network -> - val capabilities = connectivityManager.getNetworkCapabilities(network) - capabilities.isUsable() && ( - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true || - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true || - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true - ) - } - } + return hasUsableNonVpnNetwork(manager(context)) + } - return connectivityManager.activeNetworkInfo.isConnectedLegacy() + /** Compatibility alias for [hasUnderlyingNetwork]. */ + @JvmStatic + fun hasPhysicalNetwork(context: Context?): Boolean { + return hasUnderlyingNetwork(context) } /** Cheap point-in-time snapshot of the application's default network. */ @@ -736,7 +736,7 @@ class ConnectivityAndInternetAccess private constructor( val active = connectivityManager.activeNetwork ?: return disconnectedNetworkState() return networkStateFromCapabilities( - context, + connectivityManager, connectivityManager.getNetworkCapabilities(active) ) } @@ -744,7 +744,6 @@ class ConnectivityAndInternetAccess private constructor( val connected = connectivityManager.activeNetworkInfo.isConnectedLegacy() return NetworkState( connected = connected, - physicalNetworkAvailable = connected, internetValidated = false, captivePortalDetected = false, observedAtElapsedRealtime = SystemClock.elapsedRealtime() @@ -785,9 +784,8 @@ class ConnectivityAndInternetAccess private constructor( } val capabilities = manager(context).getNetworkCapabilities(network) - return capabilities != null && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + return isEffectivelyUsable(manager(context), capabilities) && + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true } /** @@ -1732,10 +1730,10 @@ class ConnectivityAndInternetAccess private constructor( } private fun networkStateFromCapabilities( - context: Context, + connectivityManager: ConnectivityManager, capabilities: NetworkCapabilities? ): NetworkState { - val connected = capabilities.isUsable() + val connected = isEffectivelyUsable(connectivityManager, capabilities) val validated = connected && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && capabilities?.hasCapability( @@ -1747,7 +1745,6 @@ class ConnectivityAndInternetAccess private constructor( ) == true return NetworkState( connected = connected, - physicalNetworkAvailable = hasPhysicalNetwork(context), internetValidated = validated, captivePortalDetected = captivePortal, observedAtElapsedRealtime = SystemClock.elapsedRealtime() @@ -1756,7 +1753,6 @@ class ConnectivityAndInternetAccess private constructor( private fun disconnectedNetworkState(): NetworkState = NetworkState( connected = false, - physicalNetworkAvailable = false, internetValidated = false, captivePortalDetected = false, observedAtElapsedRealtime = SystemClock.elapsedRealtime() @@ -1766,6 +1762,8 @@ class ConnectivityAndInternetAccess private constructor( context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: throw IllegalStateException("ConnectivityManager unavailable") + // Low-level capability check only. Use isEffectivelyUsable() for + // application connectivity, including VPN underlying-network handling. private fun NetworkCapabilities?.isUsable(): Boolean { if (this == null || !hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) @@ -1777,6 +1775,34 @@ class ConnectivityAndInternetAccess private constructor( hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) } + private fun isEffectivelyUsable( + connectivityManager: ConnectivityManager, + capabilities: NetworkCapabilities? + ): Boolean { + if (!capabilities.isUsable()) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || + !capabilities!!.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + ) return true + return hasUsableNonVpnNetwork(connectivityManager) + } + + private fun hasUsableNonVpnNetwork( + connectivityManager: ConnectivityManager + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return connectivityManager.activeNetworkInfo.isConnectedLegacy() + } + return connectivityManager.allNetworks.any { network -> + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities.isUsable() && + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + capabilities!!.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } else { + !capabilities!!.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + } + } + } + private fun hasTransport(context: Context?, transport: Int): Boolean { context ?: throw IllegalArgumentException("context == null") val connectivityManager = manager(context) @@ -1878,7 +1904,10 @@ class ConnectivityAndInternetAccess private constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val active = connectivityManager.activeNetwork if (active != null && - connectivityManager.getNetworkCapabilities(active).isUsable() + isEffectivelyUsable( + connectivityManager, + connectivityManager.getNetworkCapabilities(active) + ) ) { return active } @@ -1886,7 +1915,10 @@ class ConnectivityAndInternetAccess private constructor( } return connectivityManager.allNetworks.firstOrNull { network -> - connectivityManager.getNetworkCapabilities(network).isUsable() + isEffectivelyUsable( + connectivityManager, + connectivityManager.getNetworkCapabilities(network) + ) } } @@ -2121,7 +2153,9 @@ class ConnectivityAndInternetAccess private constructor( } attempt.closed = true connectionAttemptQueue.remove(attempt) - decrementConnectionAttempts() + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } connectionAttemptStalled.set(true) return true } @@ -2145,7 +2179,9 @@ class ConnectivityAndInternetAccess private constructor( attempt.closed = true connectionAttemptQueue.removeFirst() - decrementConnectionAttempts() + connectionAttempts.updateAndGet { value -> + if (value > 0) value - 1 else 0 + } connectionAttemptStalled.set(true) } } @@ -2191,16 +2227,6 @@ class ConnectivityAndInternetAccess private constructor( } } - /** Atomic decrement compatible with the app's API 23 minimum. */ - private fun decrementConnectionAttempts() { - while (true) { - val current = connectionAttempts.get() - if (current <= 0 || connectionAttempts.compareAndSet(current, current - 1)) { - return - } - } - } - private data class ProbeAttempt( val label: String, val operation: () -> Boolean diff --git a/PokemonJetpackCompose/app/src/main/res/values/strings.xml b/PokemonJetpackCompose/app/src/main/res/values/strings.xml index 408db5b..cc7d69d 100644 --- a/PokemonJetpackCompose/app/src/main/res/values/strings.xml +++ b/PokemonJetpackCompose/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ PokemonJetpackCompose - \ No newline at end of file + Sin conexión de red. + No se puede acceder a Internet. + Hay conexión a Internet, pero el servicio no está disponible. + La red requiere iniciar sesión para acceder a Internet. + Conexión recuperada. + diff --git a/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt index e1f24ae..ee7bb5f 100644 --- a/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt +++ b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt @@ -27,9 +27,7 @@ class AppConnectivityManagerTest { @Test fun remoteRequestsRequireAConnectedPhysicalNetwork() { - assertTrue(AppConnectivityManager.canStartRemoteRequest(true, true)) - assertFalse(AppConnectivityManager.canStartRemoteRequest(true, false)) - assertFalse(AppConnectivityManager.canStartRemoteRequest(false, true)) - assertFalse(AppConnectivityManager.canStartRemoteRequest(false, false)) + assertTrue(AppConnectivityManager.canStartRemoteRequest(true)) + assertFalse(AppConnectivityManager.canStartRemoteRequest(false)) } } diff --git a/README.md b/README.md index 94e279d..71d333a 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,20 @@ ### Política de conectividad Antes de iniciar una petición, la aplicación consulta el estado local de la red mediante -`ConnectivityAndInternetAccess.isConnected(...)` y `ConnectivityAndInternetAccess.hasPhysicalNetwork(...)`. -La segunda comprobación evita que una VPN local (por ejemplo, AdGuard) se considere conectividad -suficiente cuando no queda Wi-Fi, datos móviles ni Ethernet. Si no hay una red utilizable, la -petición se pospone y la interfaz muestra el estado offline. Cuando existe conectividad, se -ejecuta la petición real de PokéAPI con sus propios timeouts y manejo de errores, sin sondeos -activos redundantes ni enumeración de hosts. +`ConnectivityAndInternetAccess.isConnected(...)`. La implementación actual considera también +si una VPN conserva una red subyacente utilizable; si no hay una red efectiva, la petición se +pospone y la interfaz muestra el estado offline. Cuando existe conectividad, se ejecuta la +petición real de PokéAPI con sus propios timeouts y manejo de errores, sin sondeos activos +redundantes ni enumeración de hosts. El diagnóstico activo general (DNS, TCP, NTP, TLS, HTTPS e ICMP opcional) solo se ejecuta tras un fallo compatible con transporte/red o cuando el usuario pulsa “Diagnosticar”. Las respuestas HTTP 4xx/5xx se tratan como errores del servicio y no disparan ese diagnóstico. -La UI conserva un `NetworkObserver` pasivo durante su lifecycle y reacciona a la pérdida o -recuperación del transporte físico subyacente. En el caso VPN-only (VPN activa, pero Wi-Fi/datos/ -Ethernet apagados), `isConnected()` puede seguir siendo verdadero, pero `hasPhysicalNetwork()` es -falso y no se inicia la llamada remota. `NET_CAPABILITY_VALIDATED` y `CAPTIVE_PORTAL` se mantienen -como señales independientes y no sustituyen la comprobación física. +La UI conserva un `NetworkObserver` pasivo durante su lifecycle y reacciona a cambios de red, +incluidos VPN, underlying network, pérdida de validación y portal cautivo. `NET_CAPABILITY_VALIDATED` +y `CAPTIVE_PORTAL` se mantienen como señales independientes; el diagnóstico general solo se usa +como fallback tras un fallo de transporte o cuando el usuario lo solicita.