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
-[](https://github.com/mouredev)
\ No newline at end of file
+[](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.