diff --git a/PokemonJetpackCompose/app/build.gradle b/PokemonJetpackCompose/app/build.gradle
index fef8560..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 1
- versionName "1.0"
+ versionCode 4
+ versionName "1.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -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
+}
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">
+
) -> Unit, failure: () -> Unit) {
+ 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) {
- 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)
}
})
+ return call
}
-}
\ 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 e2fa8dc..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
@@ -3,22 +3,39 @@ package com.mouredev.pokemonjetpackcompose.ui.list
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
+import androidx.activity.viewModels
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
-import androidx.compose.runtime.Composable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.runtime.*
+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
+import androidx.compose.ui.unit.sp
import com.mouredev.pokemonjetpackcompose.ui.theme.PokemonJetpackComposeTheme
/**
* Created by MoureDev by Brais Moure on 28/10/22.
* www.mouredev.com
+ * Modernized with broad tiered connectivity observation and diagnostic UI.
*/
-
class PokemonListActivity : ComponentActivity() {
+
+ private val viewModel: PokemonListViewModel by viewModels()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@@ -27,43 +44,251 @@ class PokemonListActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
- PokemonList()
+ PokemonList(viewModel = viewModel)
}
}
}
}
+
+ override fun onStart() {
+ super.onStart()
+ viewModel.startObservingNetwork(this)
+ }
+
+ override fun onStop() {
+ viewModel.stopObservingNetwork()
+ super.onStop()
+ }
}
@Composable
-fun PokemonList() {
+fun PokemonList(viewModel: PokemonListViewModel) {
+ val context = LocalContext.current
+ var showDiagnosticDetails by remember { mutableStateOf(false) }
- val viewModel = PokemonListViewModel()
+ LaunchedEffect(viewModel.toastMessage) {
+ viewModel.toastMessage?.let {
+ Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
+ viewModel.consumeToast()
+ }
+ }
Scaffold(
topBar = {
TopAppBar(
- title = {
- Text("Pokémon list")
+ title = { Text("Pokémon list") },
+ actions = {
+ IconButton(onClick = {
+ viewModel.performConnectivityCheck(context)
+ }) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = "Comprobar conectividad"
+ )
+ }
}
)
}
) { padding ->
- 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 = "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
+ )
+ }
+ }
+ }
+
+ 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(context) }) {
+ 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..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,31 +1,174 @@
package com.mouredev.pokemonjetpackcompose.ui.list
-import androidx.compose.runtime.mutableStateOf
+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.
- * 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("")
+ 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) {
+ 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()
+ }
+ }
+ }
+
+ fun stopObservingNetwork() {
+ networkObserver?.close()
+ 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
+ }
- init {
- loadData()
+ isCheckingConnectivity = true
+ activeRequest?.cancel()
+ 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 {
+ "Sin acceso general a Internet (${result.attemptedHosts.joinToString()})."
+ }
+ }
}
- private fun loadData() {
+ fun loadData(context: Context) {
+ if (!AppConnectivityManager.canStartRemoteRequest(context)) {
+ markOffline()
+ return
+ }
- PokemonAPI.loadPokemon({ pokemon ->
+ isLoadingData = true
+ errorLoadingData = false
+ isAppBackendReachable = null
+
+ activeApiCall = PokemonAPI.loadPokemon({ pokemon ->
+ activeApiCall = null
+ isAppBackendReachable = true
+ isFallbackInternetReachable = true
pokemonList = pokemon
+ isLoadingData = false
+ errorLoadingData = false
+ diagnosticSummary = "PokéAPI disponible (${pokemon.size} Pokémon cargados)."
}, {
- println("Error")
+ error ->
+ activeApiCall = null
+ 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 {
+ emitToast(context, R.string.service_unavailable)
+ diagnosticSummary = "PokéAPI respondió con un error (${(error as? HttpException)?.code() ?: "desconocido"})."
+ }
})
}
-}
\ No newline at end of file
+ private fun runGeneralDiagnosis(context: Context) {
+ if (!AppConnectivityManager.canStartRemoteRequest(context)) {
+ markOffline()
+ return
+ }
+ isCheckingConnectivity = true
+ activeRequest?.cancel()
+ activeRequest = AppConnectivityManager.diagnoseGeneralInternetAsync(context) { result ->
+ 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."
+ }
+ }
+ }
+
+ private fun markOffline() {
+ isAppBackendReachable = false
+ isFallbackInternetReachable = false
+ isCheckingConnectivity = false
+ isLoadingData = false
+ 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
new file mode 100644
index 0000000..b0fcc2c
--- /dev/null
+++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/AppConnectivityManager.kt
@@ -0,0 +1,50 @@
+package com.mouredev.pokemonjetpackcompose.util
+
+import android.content.Context
+
+/**
+ * Shared connectivity policy for the Pokemon app.
+ *
+ * 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 {
+
+ private val generalConnectivity by lazy {
+ ConnectivityAndInternetAccess.Builder().build()
+ }
+
+ fun isConnected(context: Context): Boolean =
+ ConnectivityAndInternetAccess.isConnected(context)
+
+ fun canStartRemoteRequest(context: Context): Boolean =
+ isConnected(context)
+
+ internal fun canStartRemoteRequest(isConnected: Boolean): Boolean = isConnected
+
+ fun diagnoseGeneralInternetAsync(
+ context: Context,
+ onResult: (result: ConnectivityAndInternetAccess.InternetResult) -> Unit
+ ): ConnectivityAndInternetAccess.Request {
+ return generalConnectivity.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
new file mode 100644
index 0000000..c97b4c9
--- /dev/null
+++ b/PokemonJetpackCompose/app/src/main/java/com/mouredev/pokemonjetpackcompose/util/ConnectivityAndInternetAccess.kt
@@ -0,0 +1,2310 @@
+/*
+ * 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 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
+) {
+
+ /**
+ * 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,
+ DEFAULT_TCP_TARGETS,
+ DEFAULT_NTP_TARGETS,
+ DEFAULT_TLS_TARGETS,
+ DefaultDnsProbe(),
+ DefaultHttpProbe(),
+ DefaultTcpProbe(),
+ DefaultNtpProbe(),
+ DefaultTlsProbe(),
+ DEFAULT_ICMP_TARGETS
+ ) {
+ globalHosts = instanceHosts
+ }
+
+ fun interface DnsProbeStrategy {
+ fun checkDns(resolver: String, network: Network?): Boolean
+ }
+
+ fun interface HttpProbeStrategy {
+ 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)
+ }
+
+ /**
+ * 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?,
+ val attemptedHosts: List,
+ val elapsedMilliseconds: Long
+ ) {
+ 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)
+
+ @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(
+ connectivityManager, 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 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
+ }
+
+ fun setDnsResolvers(resolvers: List) = apply {
+ 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, 8.8.8.8, and the Cloudflare IPv6 resolver address.
+ */
+ fun setIcmpTargets(targets: List) = apply {
+ this.icmpTargets = targets
+ }
+
+ fun setDnsProbeStrategy(strategy: DnsProbeStrategy) = apply {
+ this.dnsStrategy = strategy
+ }
+
+ fun setHttpProbeStrategy(strategy: HttpProbeStrategy) = apply {
+ 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
+ ): Request = executeAsync(
+ context,
+ instanceResolvers,
+ instanceHosts,
+ instanceTcpTargets,
+ instanceNtpTargets,
+ instanceTlsTargets,
+ instanceDnsStrategy,
+ instanceHttpStrategy,
+ instanceTcpStrategy,
+ instanceNtpStrategy,
+ instanceTlsStrategy,
+ callback
+ )
+
+ fun checkInternetBlocking(context: Context): InternetResult = executeBlocking(
+ context,
+ instanceResolvers,
+ instanceHosts,
+ instanceTcpTargets,
+ instanceNtpTargets,
+ instanceTlsTargets,
+ instanceDnsStrategy,
+ instanceHttpStrategy,
+ instanceTcpStrategy,
+ instanceNtpStrategy,
+ instanceTlsStrategy
+ )
+
+ /**
+ * 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 = 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"
+
+ private val DEFAULT_DNS_RESOLVERS = listOf(
+ "1.1.1.1",
+ "8.8.8.8",
+ "9.9.9.9",
+ "208.67.222.222",
+ "[2606:4700:4700::1111]"
+ )
+
+ 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/"
+ )
+
+ /**
+ * 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",
+ "[2606:4700:4700::1111]"
+ )
+
+ @Volatile
+ private var globalHosts: List = DEFAULT_HOSTS
+
+ @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)
+ private val connectionAttemptStalled = AtomicBoolean(false)
+ private var legacyConnectingSinceElapsedRealtime = -1L
+ 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())
+ .setTcpTargets(emptyList())
+ .setNtpTargets(emptyList())
+ .setTlsTargets(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
+ }
+ val connectivityManager = manager(context)
+ return isEffectivelyUsable(
+ connectivityManager,
+ connectivityManager.getNetworkCapabilities(network)
+ )
+ }
+
+ @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)
+ 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 isConnectionAttemptStalled(context: Context?): Boolean {
+ context ?: throw IllegalArgumentException("context == null")
+
+ 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(SystemClock.elapsedRealtime())
+ synchronized(connectionAttemptLock) {
+ if (connectionAttempts.get() == 0) {
+ connectionAttemptStalled.set(false)
+ }
+ connectionAttemptQueue.addLast(attempt)
+ connectionAttempts.incrementAndGet()
+ }
+ mainHandler.postDelayed(
+ {
+ if (!isConnected(safeContext)) {
+ timeoutConnectionAttempt(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 &&
+ isEffectivelyUsable(
+ connectivityManager,
+ connectivityManager.getNetworkCapabilities(active)
+ )
+ ) {
+ clearConnectionAttempts()
+ return true
+ }
+ return false
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ if (connectivityManager.allNetworks.any { network ->
+ isEffectivelyUsable(
+ connectivityManager,
+ connectivityManager.getNetworkCapabilities(network)
+ )
+ }
+ ) {
+ clearConnectionAttempts()
+ return true
+ }
+ return false
+ }
+
+ val connected = connectivityManager.activeNetworkInfo.isConnectedLegacy()
+ if (connected) {
+ clearConnectionAttempts()
+ }
+ return connected
+ }
+
+ /** Returns whether a usable non-VPN network exists beneath the active path. */
+ @JvmStatic
+ fun hasUnderlyingNetwork(context: Context?): Boolean {
+ context ?: throw IllegalArgumentException("context == null")
+ return hasUsableNonVpnNetwork(manager(context))
+ }
+
+ /** Compatibility alias for [hasUnderlyingNetwork]. */
+ @JvmStatic
+ fun hasPhysicalNetwork(context: Context?): Boolean {
+ return hasUnderlyingNetwork(context)
+ }
+
+ /** 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,
+ 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 isEffectivelyUsable(manager(context), capabilities) &&
+ capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
+ }
+
+ /**
+ * 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,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy
+ ).reachable
+ }
+
+ @JvmStatic
+ fun isInternetReachable(
+ context: Context?,
+ hosts: ArrayList
+ ): Boolean {
+ context ?: throw IllegalArgumentException("context == null")
+ return executeBlocking(
+ context,
+ globalResolvers,
+ normalizeHosts(hosts),
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy
+ ).reachable
+ }
+
+ @JvmStatic
+ fun checkInternetAsyncDefault(
+ context: Context,
+ callback: InternetCallback
+ ): Request = executeAsync(
+ context,
+ globalResolvers,
+ globalHosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy,
+ callback
+ )
+
+ @JvmStatic
+ fun checkInternetAsyncDefault(
+ context: Context,
+ hosts: List,
+ callback: InternetCallback
+ ): Request = executeAsync(
+ context,
+ globalResolvers,
+ hosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy,
+ callback
+ )
+
+ @JvmStatic
+ fun checkInternetAsyncDefault(
+ context: Context,
+ dnsResolvers: List,
+ hosts: List,
+ callback: InternetCallback
+ ): Request = executeAsync(
+ context,
+ dnsResolvers,
+ hosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy,
+ callback
+ )
+
+ @JvmStatic
+ fun checkInternetBlockingDefault(
+ context: Context
+ ): InternetResult = executeBlocking(
+ context,
+ globalResolvers,
+ globalHosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy
+ )
+
+ @JvmStatic
+ fun checkInternetBlockingDefault(
+ context: Context,
+ hosts: List
+ ): InternetResult = executeBlocking(
+ context,
+ globalResolvers,
+ hosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy
+ )
+
+ @JvmStatic
+ fun checkInternetBlockingDefault(
+ context: Context,
+ dnsResolvers: List,
+ hosts: List
+ ): InternetResult = executeBlocking(
+ context,
+ dnsResolvers,
+ hosts,
+ globalTcpTargets,
+ globalNtpTargets,
+ globalTlsTargets,
+ globalDnsStrategy,
+ globalHttpStrategy,
+ globalTcpStrategy,
+ globalNtpStrategy,
+ globalTlsStrategy
+ )
+
+ @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 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
+
+ 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", stripAddressBrackets(target))
+ .redirectErrorStream(true)
+ .start()
+ } catch (primaryFailure: IOException) {
+ try {
+ ProcessBuilder("ping", "-c", "1", stripAddressBrackets(target))
+ .redirectErrorStream(true)
+ .start()
+ } catch (fallbackFailure: IOException) {
+ throw fallbackFailure
+ }
+ }
+
+ private fun executeAsync(
+ 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(
+ executor.submit {
+ val result = executeBlocking(
+ appContext,
+ normalizedResolvers,
+ normalizedHosts,
+ normalizedTcpTargets,
+ normalizedNtpTargets,
+ normalizedTlsTargets,
+ dnsStrategy,
+ httpStrategy,
+ tcpStrategy,
+ ntpStrategy,
+ tlsStrategy
+ )
+
+ if (!request.isCancelled()) {
+ mainHandler.post {
+ if (!request.isCancelled()) {
+ callback.onResult(result)
+ }
+ }
+ }
+ }
+ )
+
+ return request
+ }
+
+ private fun executeBlocking(
+ context: Context,
+ dnsResolvers: List,
+ hosts: List,
+ tcpTargets: List,
+ ntpTargets: List,
+ tlsTargets: List,
+ dnsStrategy: DnsProbeStrategy,
+ httpStrategy: HttpProbeStrategy,
+ tcpStrategy: TcpProbeStrategy,
+ ntpStrategy: NtpProbeStrategy,
+ tlsStrategy: TlsProbeStrategy
+ ): 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?
+
+ 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 transportAttempts = mutableListOf()
+ normalizeDnsResolvers(dnsResolvers).forEach { resolver ->
+ transportAttempts += ProbeAttempt(dnsEndpointLabel(resolver)) {
+ dnsStrategy.checkDns(resolver, network)
+ }
+ }
+ 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)
+ }
+ }
+ normalizeNtpTargets(ntpTargets).forEach { host ->
+ transportAttempts += ProbeAttempt("ntp://${formatHost(host)}:$NTP_PORT") {
+ ntpStrategy.checkNtp(host, network)
+ }
+ }
+
+ reached = raceProbes(transportAttempts, attempted, minOf(deadline, started + DNS_STAGE_TIMEOUT_MS), probeExecutor)
+ 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)
+ }
+ }
+
+ 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()
+ }
+ }
+
+ 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"
+
+ 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 = parseEndpoint(resolver, DNS_PORT)
+ 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(resolveAddress(endpoint.host, network), 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(
+ connectivityManager: ConnectivityManager,
+ capabilities: NetworkCapabilities?
+ ): NetworkState {
+ val connected = isEffectivelyUsable(connectivityManager, capabilities)
+ 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")
+
+ // 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)
+ ) {
+ return false
+ }
+
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
+ 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)
+
+ 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 &&
+ isEffectivelyUsable(
+ connectivityManager,
+ connectivityManager.getNetworkCapabilities(active)
+ )
+ ) {
+ return active
+ }
+ return null
+ }
+
+ return connectivityManager.allNetworks.firstOrNull { network ->
+ isEffectivelyUsable(
+ connectivityManager,
+ connectivityManager.getNetworkCapabilities(network)
+ )
+ }
+ }
+
+ 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.
+ */
+ val processTarget = stripAddressBrackets(value)
+ require(
+ !value.startsWith("-") &&
+ !processTarget.startsWith("-") &&
+ processTarget.isNotEmpty() &&
+ processTarget.matches(Regex("[A-Za-z0-9._:%-]+"))
+ ) {
+ "Invalid ICMP target: $value"
+ }
+
+ normalized += value
+ }
+
+ return normalized.toList()
+ }
+
+ 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()) {
+ 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()
+ (parsed.protocol.equals("http", ignoreCase = true) ||
+ parsed.protocol.equals("https", ignoreCase = true)) &&
+ parsed.host.isNotEmpty()
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ 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 {
+ trimmed
+ }
+ }
+
+ 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 endpoint"
+ }
+
+ val host: String
+ var port = defaultPort
+
+ if (value.startsWith("[")) {
+ val closingBracket = value.indexOf(']')
+ 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(":") && 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) {
+ 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()) {
+ "Invalid endpoint: $target"
+ }
+
+ return Endpoint(host, port)
+ }
+
+ private fun parsePort(rawPort: String): Int {
+ val port = rawPort.trim().toIntOrNull()
+ ?: throw IllegalArgumentException("Invalid endpoint port")
+ require(port in 1..65_535) {
+ "Invalid endpoint port"
+ }
+ 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,
+ ThreadFactory { runnable ->
+ Thread(
+ runnable,
+ "connectivity-probe-${probeThreadNumber.incrementAndGet()}"
+ ).apply {
+ isDaemon = true
+ }
+ }
+ )
+ private fun timeoutConnectionAttempt(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
+ }
+ 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 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 ->
+ attempt.closed = true
+ }
+ connectionAttemptQueue.clear()
+ connectionAttempts.set(0)
+ connectionAttemptStalled.set(false)
+ legacyConnectingSinceElapsedRealtime = -1L
+ }
+ }
+
+ private data class ProbeAttempt(
+ val label: String,
+ val operation: () -> Boolean
+ )
+
+ private data class Endpoint(
+ val host: String,
+ val port: Int
+ )
+ private class ConnectionAttempt(
+ val startedAtElapsedRealtime: Long
+ ) {
+ 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/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
new file mode 100644
index 0000000..ee7bb5f
--- /dev/null
+++ b/PokemonJetpackCompose/app/src/test/java/com/mouredev/pokemonjetpackcompose/AppConnectivityManagerTest.kt
@@ -0,0 +1,33 @@
+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")))
+ }
+
+ @Test
+ fun remoteRequestsRequireAConnectedPhysicalNetwork() {
+ assertTrue(AppConnectivityManager.canStartRemoteRequest(true))
+ assertFalse(AppConnectivityManager.canStartRemoteRequest(false))
+ }
+}
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
diff --git a/README.md b/README.md
index 4aaf367..71d333a 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,24 @@
* [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 el estado local de la red mediante
+`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 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.
+
También tienes versión de esta aplicación en iOS
@@ -40,4 +58,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)