Implemented VPN/DNS resilience

This commit is contained in:
2026-05-11 17:04:04 +03:30
parent 7b31e14eeb
commit 91f8eda94a
12 changed files with 557 additions and 54 deletions

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -41,12 +41,14 @@ class MonitorController(context: Context) {
val summaries = repository.latestSummaries(servers.map { it.id }) val summaries = repository.latestSummaries(servers.map { it.id })
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty() val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
val alerts = repository.alerts() val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
_state.update { _state.update {
it.copy( it.copy(
servers = servers, servers = servers,
selectedServerId = selected, selectedServerId = selected,
summaries = summaries, summaries = summaries,
history = history, history = history,
routeDiagnostics = routeDiagnostics,
alerts = alerts, alerts = alerts,
globalMessage = null, globalMessage = null,
) )
@@ -140,10 +142,12 @@ class MonitorController(context: Context) {
val servers = repository.servers() val servers = repository.servers()
val summaries = repository.latestSummaries(servers.map { it.id }) val summaries = repository.latestSummaries(servers.map { it.id })
val history = repository.history(server.id) val history = repository.history(server.id)
val routeDiagnostics = repository.routeDiagnostics()
_state.update { _state.update {
it.copy( it.copy(
servers = servers, servers = servers,
summaries = summaries, summaries = summaries,
routeDiagnostics = routeDiagnostics,
selectedServerId = server.id, selectedServerId = server.id,
showDetail = true, showDetail = true,
selectedTab = DetailTab.Overview, selectedTab = DetailTab.Overview,
@@ -191,6 +195,7 @@ class MonitorController(context: Context) {
selectedTab = DetailTab.Overview, selectedTab = DetailTab.Overview,
summaries = repository.latestSummaries(servers.map { item -> item.id }), summaries = repository.latestSummaries(servers.map { item -> item.id }),
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(), history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
routeDiagnostics = repository.routeDiagnostics(),
alerts = repository.alerts(), alerts = repository.alerts(),
globalMessage = "Server deleted", globalMessage = "Server deleted",
) )
@@ -233,10 +238,12 @@ class MonitorController(context: Context) {
val selected = _state.value.selectedServerId val selected = _state.value.selectedServerId
val history = selected?.let { repository.history(it) } val history = selected?.let { repository.history(it) }
val alerts = repository.alerts() val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
_state.update { _state.update {
it.copy( it.copy(
alerts = alerts, alerts = alerts,
routeDiagnostics = routeDiagnostics,
history = if (selected != null && history != null) it.history + (selected to history) else it.history, history = if (selected != null && history != null) it.history + (selected to history) else it.history,
) )
} }

View File

@@ -10,12 +10,16 @@ import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.RoutePolicy
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import org.json.JSONArray import org.json.JSONArray
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) { class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 2) {
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
""" """
@@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
base_url TEXT NOT NULL, base_url TEXT NOT NULL,
fallback_ips_json TEXT NOT NULL,
route_policy TEXT NOT NULL,
tags_json TEXT NOT NULL, tags_json TEXT NOT NULL,
favorite INTEGER NOT NULL, favorite INTEGER NOT NULL,
allow_http INTEGER NOT NULL, allow_http INTEGER NOT NULL,
@@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
""".trimIndent(), """.trimIndent(),
) )
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)") db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
createRouteDiagnosticsTable(db)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE servers ADD COLUMN fallback_ips_json TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE servers ADD COLUMN route_policy TEXT NOT NULL DEFAULT '${RoutePolicy.AutoFallback.name}'")
createRouteDiagnosticsTable(db)
}
}
@Synchronized @Synchronized
fun listServers(): List<ServerProfile> { fun listServers(): List<ServerProfile> {
@@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
writableDatabase.delete("servers", "id = ?", arrayOf(serverId)) writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId)) writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId)) writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId))
} }
@Synchronized @Synchronized
@@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
return result return result
} }
@Synchronized
fun upsertRouteDiagnostics(diagnostics: RouteDiagnostics) {
writableDatabase.insertWithOnConflict(
"route_diagnostics",
null,
diagnostics.toValues(),
SQLiteDatabase.CONFLICT_REPLACE,
)
}
@Synchronized
fun routeDiagnostics(): Map<String, RouteDiagnostics> {
val result = linkedMapOf<String, RouteDiagnostics>()
readableDatabase.query("route_diagnostics", null, null, null, null, null, "timestamp_millis DESC").use { cursor ->
while (cursor.moveToNext()) {
val diagnostics = cursor.toRouteDiagnostics()
result[diagnostics.serverId] = diagnostics
}
}
return result
}
@Synchronized @Synchronized
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) { fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong()) val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
@@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("id", id) put("id", id)
put("name", name) put("name", name)
put("base_url", baseUrl) put("base_url", baseUrl)
put("fallback_ips_json", listToJson(fallbackIpAddresses))
put("route_policy", routePolicy.name)
put("tags_json", tagsToJson(tags)) put("tags_json", tagsToJson(tags))
put("favorite", favorite.asInt()) put("favorite", favorite.asInt())
put("allow_http", allowHttp.asInt()) put("allow_http", allowHttp.asInt())
@@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("resolved", resolved.asInt()) put("resolved", resolved.asInt())
} }
private fun RouteDiagnostics.toValues() = ContentValues().apply {
put("server_id", serverId)
put("timestamp_millis", timestampMillis)
put("active_network", activeNetwork)
put("route_used", routeUsed.name)
put("dns_result", dnsResult)
put("issue", issue.name)
put("vpn_active", vpnActive.asInt())
put("fallback_used", fallbackUsed.asInt())
put("success", success.asInt())
}
private fun Cursor.toServer(): ServerProfile { private fun Cursor.toServer(): ServerProfile {
return ServerProfile( return ServerProfile(
id = string("id"), id = string("id"),
name = string("name"), name = string("name"),
baseUrl = string("base_url"), baseUrl = string("base_url"),
fallbackIpAddresses = listFromJson(string("fallback_ips_json")),
routePolicy = runCatching { RoutePolicy.valueOf(string("route_policy")) }.getOrDefault(RoutePolicy.AutoFallback),
tags = tagsFromJson(string("tags_json")), tags = tagsFromJson(string("tags_json")),
favorite = int("favorite") == 1, favorite = int("favorite") == 1,
allowHttp = int("allow_http") == 1, allowHttp = int("allow_http") == 1,
@@ -294,17 +346,55 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
) )
} }
private fun Cursor.toRouteDiagnostics(): RouteDiagnostics {
return RouteDiagnostics(
serverId = string("server_id"),
timestampMillis = long("timestamp_millis"),
activeNetwork = string("active_network"),
routeUsed = runCatching { RouteKind.valueOf(string("route_used")) }.getOrDefault(RouteKind.SystemDns),
dnsResult = string("dns_result"),
issue = runCatching { NetworkIssue.valueOf(string("issue")) }.getOrDefault(NetworkIssue.Unknown),
vpnActive = int("vpn_active") == 1,
fallbackUsed = int("fallback_used") == 1,
success = int("success") == 1,
)
}
private fun tagsToJson(tags: List<String>): String { private fun tagsToJson(tags: List<String>): String {
return listToJson(tags)
}
private fun listToJson(values: List<String>): String {
val array = JSONArray() val array = JSONArray()
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put) values.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
return array.toString() return array.toString()
} }
private fun tagsFromJson(value: String): List<String> = runCatching { private fun tagsFromJson(value: String): List<String> = listFromJson(value)
private fun listFromJson(value: String): List<String> = runCatching {
val array = JSONArray(value) val array = JSONArray(value)
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() } List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
private fun createRouteDiagnosticsTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS route_diagnostics (
server_id TEXT PRIMARY KEY,
timestamp_millis INTEGER NOT NULL,
active_network TEXT NOT NULL,
route_used TEXT NOT NULL,
dns_result TEXT NOT NULL,
issue TEXT NOT NULL,
vpn_active INTEGER NOT NULL,
fallback_used INTEGER NOT NULL,
success INTEGER NOT NULL
)
""".trimIndent(),
)
}
private fun Boolean.asInt() = if (this) 1 else 0 private fun Boolean.asInt() = if (this) 1 else 0
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value) private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)

View File

@@ -1,35 +1,70 @@
package net.rodakot.ngxhttpmonitoringclient.data package net.rodakot.ngxhttpmonitoringclient.data
import android.util.Base64 import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import net.rodakot.ngxhttpmonitoringclient.domain.RouteAttemptPlanner
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import okhttp3.Credentials
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InterruptedIOException
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader import java.io.InputStreamReader
import java.net.HttpURLConnection import java.net.ConnectException
import java.net.URL import java.net.InetAddress
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
class MonitorHttpClient { class MonitorHttpClient(context: Context) {
fun fetchApi(server: ServerProfile, auth: AuthConfig): String { private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth) private val baseClient = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(20, TimeUnit.SECONDS)
.build()
fun fetchApi(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/api"))
} }
fun fetchHealth(server: ServerProfile, auth: AuthConfig): String { fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth) return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health"))
} }
fun streamLive(server: ServerProfile, auth: AuthConfig, onMetrics: (String) -> Unit) { fun streamLive(
val connection = openConnection(UrlRules.endpoint(server.baseUrl, "/monitor/live"), auth).apply { server: ServerProfile,
readTimeout = 0 auth: AuthConfig,
setRequestProperty("Accept", "text/event-stream") onDiagnostics: (RouteDiagnostics) -> Unit,
} onMetrics: (String) -> Unit,
try { ) {
val status = connection.responseCode val routed = openRoutedResponse(
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus()) server = server,
BufferedReader(InputStreamReader(connection.inputStream, StandardCharsets.UTF_8)).use { reader -> auth = auth,
url = UrlRules.endpoint(server.baseUrl, "/monitor/live"),
accept = "text/event-stream",
streaming = true,
)
onDiagnostics(routed.diagnostics)
routed.response.use { response ->
if (!response.isSuccessful) {
throw response.toException(routed.diagnostics)
}
BufferedReader(InputStreamReader(response.body!!.byteStream(), StandardCharsets.UTF_8)).use { reader ->
var eventName = "message" var eventName = "message"
val data = StringBuilder() val data = StringBuilder()
while (!Thread.currentThread().isInterrupted) { while (!Thread.currentThread().isInterrupted) {
@@ -47,41 +82,172 @@ class MonitorHttpClient {
} }
} }
} }
} finally {
connection.disconnect()
} }
} }
private fun fetchText(url: String, auth: AuthConfig): String { private fun fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse {
val connection = openConnection(url, auth) val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false)
return try { routed.response.use { response ->
val status = connection.responseCode if (!response.isSuccessful) {
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus()) throw response.toException(routed.diagnostics)
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } }
} catch (exception: IOException) { val body = response.body?.string().orEmpty()
throw MonitorClientException(null, ServerStatus.Offline, exception.message ?: "Network error", exception) return RouteResponse(body, routed.diagnostics)
} finally {
connection.disconnect()
} }
} }
private fun openConnection(url: String, auth: AuthConfig): HttpURLConnection { private fun openRoutedResponse(
val connection = URL(url).openConnection() as HttpURLConnection server: ServerProfile,
connection.connectTimeout = 8_000 auth: AuthConfig,
connection.readTimeout = 15_000 url: String,
connection.requestMethod = "GET" accept: String,
connection.setRequestProperty("Accept", "application/json") streaming: Boolean,
): RoutedResponse {
val state = networkState()
val attempts = RouteAttemptPlanner.attempts(
hasFallbackAliases = server.fallbackIpAddresses.isNotEmpty(),
vpnActive = state.vpnActive,
lanAvailable = state.lanNetwork != null,
)
var lastDiagnostics: RouteDiagnostics? = null
var lastException: IOException? = null
attempts.forEach { kind ->
val diagnostics = diagnosticsFor(server, kind, state)
try {
val response = clientFor(kind, state, server, streaming)
.newCall(request(url, auth, accept))
.execute()
return RoutedResponse(response, diagnostics.copy(success = true, issue = NetworkIssue.None))
} catch (exception: IOException) {
lastDiagnostics = diagnostics.copy(success = false, issue = classify(exception, kind))
lastException = exception
}
}
val diagnostics = lastDiagnostics ?: RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
vpnActive = state.vpnActive,
issue = NetworkIssue.Unknown,
)
throw MonitorClientException(
httpStatus = null,
monitorStatus = ServerStatus.Offline,
message = diagnostics.issue.label,
diagnostics = diagnostics,
cause = lastException,
)
}
private fun clientFor(
kind: RouteKind,
state: NetworkState,
server: ServerProfile,
streaming: Boolean,
): OkHttpClient {
val builder = baseClient.newBuilder()
.readTimeout(if (streaming) 0 else 15, TimeUnit.SECONDS)
.callTimeout(if (streaming) 0 else 20, TimeUnit.SECONDS)
val lanNetwork = state.lanNetwork
val dnsDelegate = when {
kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns -> lanNetwork?.let(::NetworkDns) ?: Dns.SYSTEM
else -> Dns.SYSTEM
}
if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
lanNetwork?.let { builder.socketFactory(it.socketFactory) }
}
if (kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns) {
builder.dns(FallbackDns(server.hostName(), server.fallbackIpAddresses, dnsDelegate))
} else {
builder.dns(dnsDelegate)
}
return builder.build()
}
private fun request(url: String, auth: AuthConfig, accept: String): Request {
val builder = Request.Builder()
.url(url)
.get()
.header("Accept", accept)
auth.token?.takeIf { it.isNotBlank() }?.let { auth.token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Monitor-Token", it) builder.header("X-Monitor-Token", it)
} }
if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) { if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) {
val encoded = Base64.encodeToString( builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword))
"${auth.basicUsername}:${auth.basicPassword}".toByteArray(StandardCharsets.UTF_8),
Base64.NO_WRAP,
)
connection.setRequestProperty("Authorization", "Basic $encoded")
} }
return connection return builder.build()
}
private fun diagnosticsFor(server: ServerProfile, kind: RouteKind, state: NetworkState): RouteDiagnostics {
val fallbackUsed = kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns
val dnsResult = when (kind) {
RouteKind.SystemDns -> "System DNS"
RouteKind.FallbackDns -> server.fallbackIpAddresses.joinToString(", ")
RouteKind.LanSystemDns -> "LAN DNS"
RouteKind.LanFallbackDns -> server.fallbackIpAddresses.joinToString(", ")
}
return RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
routeUsed = kind,
dnsResult = dnsResult.ifBlank { "None" },
issue = if (state.vpnActive) NetworkIssue.VpnActive else NetworkIssue.None,
vpnActive = state.vpnActive,
fallbackUsed = fallbackUsed,
success = false,
)
}
private fun networkState(): NetworkState {
val networks = connectivityManager?.allNetworks.orEmpty()
val activeLabel = connectivityManager?.activeNetwork
?.let { network -> connectivityManager.getNetworkCapabilities(network)?.transportLabel() }
?: "No active network"
val vpnActive = networks.any { network ->
connectivityManager?.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
val lanNetwork = networks.firstOrNull { network ->
val capabilities = connectivityManager?.getNetworkCapabilities(network)
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
}
return NetworkState(activeLabel, vpnActive, lanNetwork)
}
private fun classify(exception: IOException, kind: RouteKind): NetworkIssue {
return when (exception) {
is UnknownHostException -> NetworkIssue.DnsFailure
is SocketTimeoutException -> NetworkIssue.Timeout
is SSLException -> NetworkIssue.TlsFailure
is NoRouteToHostException -> NetworkIssue.LanRouteBlocked
is ConnectException -> if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
NetworkIssue.LanRouteBlocked
} else {
NetworkIssue.Timeout
}
is InterruptedIOException -> NetworkIssue.Timeout
else -> NetworkIssue.Unknown
}
}
private fun Response.toException(diagnostics: RouteDiagnostics): MonitorClientException {
val issue = code.toNetworkIssue()
return MonitorClientException(
httpStatus = code,
monitorStatus = code.toMonitorStatus(),
message = code.messageForStatus(),
diagnostics = diagnostics.copy(success = false, issue = issue),
)
}
private fun Int.toNetworkIssue(): NetworkIssue = when (this) {
401 -> NetworkIssue.AuthFailure
429 -> NetworkIssue.RateLimited
else -> NetworkIssue.ApiFailure
} }
private fun Int.toMonitorStatus(): ServerStatus = when (this) { private fun Int.toMonitorStatus(): ServerStatus = when (this) {
@@ -99,11 +265,58 @@ class MonitorHttpClient {
429 -> "Monitor rate limit was exceeded" 429 -> "Monitor rate limit was exceeded"
else -> "HTTP $this from monitor endpoint" else -> "HTTP $this from monitor endpoint"
} }
private fun NetworkCapabilities.transportLabel(): String {
return buildList {
if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("Cellular")
}.ifEmpty { listOf("Unknown") }.joinToString(" + ")
}
private fun ServerProfile.hostName(): String = java.net.URI(baseUrl).host.orEmpty()
}
data class RouteResponse(
val body: String,
val diagnostics: RouteDiagnostics,
)
private data class RoutedResponse(
val response: Response,
val diagnostics: RouteDiagnostics,
)
private data class NetworkState(
val activeNetworkLabel: String,
val vpnActive: Boolean,
val lanNetwork: Network?,
)
private class FallbackDns(
private val hostName: String,
private val fallbackIps: List<String>,
private val delegate: Dns,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (hostname.equals(hostName, ignoreCase = true) && fallbackIps.isNotEmpty()) {
return fallbackIps.map { InetAddress.getByName(it) }
}
return delegate.lookup(hostname)
}
}
private class NetworkDns(private val network: Network) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return network.getAllByName(hostname).toList()
}
} }
class MonitorClientException( class MonitorClientException(
val httpStatus: Int?, val httpStatus: Int?,
val monitorStatus: ServerStatus, val monitorStatus: ServerStatus,
override val message: String, override val message: String,
val diagnostics: RouteDiagnostics,
cause: Throwable? = null, cause: Throwable? = null,
) : Exception(message, cause) ) : Exception(message, cause)

View File

@@ -18,7 +18,7 @@ import org.json.JSONException
class MonitorRepository(context: Context) { class MonitorRepository(context: Context) {
private val database = MonitorDatabase(context.applicationContext) private val database = MonitorDatabase(context.applicationContext)
private val cipher = CredentialCipher() private val cipher = CredentialCipher()
private val client = MonitorHttpClient() private val client = MonitorHttpClient(context.applicationContext)
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30) private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
fun servers(): List<ServerProfile> = database.listServers() fun servers(): List<ServerProfile> = database.listServers()
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId) fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
fun routeDiagnostics(): Map<String, net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics> = database.routeDiagnostics()
fun alerts(): List<AlertEvent> = database.alerts() fun alerts(): List<AlertEvent> = database.alerts()
fun draftFor(server: ServerProfile?): ServerEditorDraft { fun draftFor(server: ServerProfile?): ServerEditorDraft {
@@ -35,6 +37,7 @@ class MonitorRepository(context: Context) {
id = server.id, id = server.id,
name = server.name, name = server.name,
baseUrl = server.baseUrl, baseUrl = server.baseUrl,
fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "),
tags = server.tags.joinToString(", "), tags = server.tags.joinToString(", "),
favorite = server.favorite, favorite = server.favorite,
allowHttp = server.allowHttp, allowHttp = server.allowHttp,
@@ -63,6 +66,7 @@ class MonitorRepository(context: Context) {
id = draft.id ?: UUID.randomUUID().toString(), id = draft.id ?: UUID.randomUUID().toString(),
name = draft.name.trim().ifBlank { baseUrl }, name = draft.name.trim().ifBlank { baseUrl },
baseUrl = baseUrl, baseUrl = baseUrl,
fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses),
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(), tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
favorite = draft.favorite, favorite = draft.favorite,
allowHttp = draft.allowHttp, allowHttp = draft.allowHttp,
@@ -91,8 +95,9 @@ class MonitorRepository(context: Context) {
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching { fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
val server = buildServer(draft.copy(id = draft.id ?: "test")) val server = buildServer(draft.copy(id = draft.id ?: "test"))
client.fetchHealth(server, authFor(server)) val response = client.fetchHealth(server, authFor(server))
"Connection works" database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id))
"Connection works through ${response.diagnostics.routeUsed.label}"
} }
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary { fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
@@ -101,17 +106,19 @@ class MonitorRepository(context: Context) {
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult { fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
return try { return try {
val payload = client.fetchApi(server, authFor(server)) val response = client.fetchApi(server, authFor(server))
val parsed = MonitorJsonParser.parse(server.id, payload) database.upsertRouteDiagnostics(response.diagnostics)
val parsed = MonitorJsonParser.parse(server.id, response.body)
val events = AlertEvaluator.evaluate(server, parsed) val events = AlertEvaluator.evaluate(server, parsed)
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
val summary = parsed.copy( val summary = parsed.copy(
status = status, status = status,
message = if (status == ServerStatus.Online) "Live" else "Threshold breached", message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
) )
persistSample(server, summary, payload, forcePersist) persistSample(server, summary, response.body, forcePersist)
RefreshResult(summary, recordAlerts(server, summary)) RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: MonitorClientException) { } catch (exception: MonitorClientException) {
database.upsertRouteDiagnostics(exception.diagnostics)
val summary = MetricSummary( val summary = MetricSummary(
serverId = server.id, serverId = server.id,
timestampMillis = System.currentTimeMillis(), timestampMillis = System.currentTimeMillis(),
@@ -142,7 +149,11 @@ class MonitorRepository(context: Context) {
} }
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) { fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
client.streamLive(server, authFor(server)) { payload -> client.streamLive(
server = server,
auth = authFor(server),
onDiagnostics = database::upsertRouteDiagnostics,
) { payload ->
val parsed = MonitorJsonParser.parse(server.id, payload) val parsed = MonitorJsonParser.parse(server.id, payload)
val events = AlertEvaluator.evaluate(server, parsed) val events = AlertEvaluator.evaluate(server, parsed)
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded

View File

@@ -0,0 +1,19 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
object RouteAttemptPlanner {
fun attempts(
hasFallbackAliases: Boolean,
vpnActive: Boolean,
lanAvailable: Boolean,
): List<RouteKind> {
val result = mutableListOf(RouteKind.SystemDns)
if (hasFallbackAliases) result += RouteKind.FallbackDns
if (vpnActive && lanAvailable) {
result += RouteKind.LanSystemDns
if (hasFallbackAliases) result += RouteKind.LanFallbackDns
}
return result.distinct()
}
}

View File

@@ -1,6 +1,7 @@
package net.rodakot.ngxhttpmonitoringclient.domain package net.rodakot.ngxhttpmonitoringclient.domain
import java.net.URI import java.net.URI
import java.net.InetAddress
object UrlRules { object UrlRules {
fun normalizeBaseUrl(input: String): String { fun normalizeBaseUrl(input: String): String {
@@ -30,4 +31,16 @@ object UrlRules {
val normalizedPath = if (path.startsWith("/")) path else "/$path" val normalizedPath = if (path.startsWith("/")) path else "/$path"
return normalizeBaseUrl(baseUrl) + normalizedPath return normalizeBaseUrl(baseUrl) + normalizedPath
} }
fun parseFallbackIps(input: String): List<String> {
return input
.split(',', '\n', ' ')
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
.onEach { value ->
runCatching { InetAddress.getByName(value) }
.getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") }
}
}
} }

View File

@@ -13,6 +13,8 @@ data class ServerProfile(
val id: String, val id: String,
val name: String, val name: String,
val baseUrl: String, val baseUrl: String,
val fallbackIpAddresses: List<String> = emptyList(),
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),
val favorite: Boolean = false, val favorite: Boolean = false,
val allowHttp: Boolean = false, val allowHttp: Boolean = false,
@@ -30,6 +32,49 @@ data class AuthConfig(
val basicPassword: String? = null, val basicPassword: String? = null,
) )
enum class RoutePolicy {
AutoFallback,
}
enum class RouteKind(val label: String) {
SystemDns("System DNS"),
FallbackDns("Fallback DNS"),
LanSystemDns("LAN DNS"),
LanFallbackDns("LAN fallback"),
}
enum class NetworkIssue(val label: String) {
None("OK"),
DnsFailure("DNS failure"),
VpnActive("VPN active"),
LanRouteBlocked("LAN route blocked"),
Timeout("Timeout"),
TlsFailure("TLS failure"),
AuthFailure("Authentication failed"),
ApiFailure("API failure"),
RateLimited("Rate limited"),
Unknown("Network error"),
}
data class RouteDiagnostics(
val serverId: String,
val timestampMillis: Long,
val activeNetwork: String = "Unknown",
val routeUsed: RouteKind = RouteKind.SystemDns,
val dnsResult: String = "System DNS",
val issue: NetworkIssue = NetworkIssue.None,
val vpnActive: Boolean = false,
val fallbackUsed: Boolean = false,
val success: Boolean = false,
) {
val summary: String
get() = if (success) {
"Connected through ${routeUsed.label}"
} else {
issue.label
}
}
data class AlertOverrides( data class AlertOverrides(
val cpuPercent: Double? = null, val cpuPercent: Double? = null,
val memoryPercent: Double? = null, val memoryPercent: Double? = null,
@@ -87,6 +132,7 @@ data class ServerEditorDraft(
val id: String? = null, val id: String? = null,
val name: String = "", val name: String = "",
val baseUrl: String = "", val baseUrl: String = "",
val fallbackIpAddresses: String = "",
val tags: String = "", val tags: String = "",
val favorite: Boolean = false, val favorite: Boolean = false,
val allowHttp: Boolean = false, val allowHttp: Boolean = false,
@@ -118,6 +164,7 @@ data class MonitorUiState(
val servers: List<ServerProfile> = emptyList(), val servers: List<ServerProfile> = emptyList(),
val summaries: Map<String, MetricSummary> = emptyMap(), val summaries: Map<String, MetricSummary> = emptyMap(),
val history: Map<String, List<MetricSummary>> = emptyMap(), val history: Map<String, List<MetricSummary>> = emptyMap(),
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
val alerts: List<AlertEvent> = emptyList(), val alerts: List<AlertEvent> = emptyList(),
val selectedServerId: String? = null, val selectedServerId: String? = null,
val showDetail: Boolean = false, val showDetail: Boolean = false,

View File

@@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
@@ -204,6 +206,7 @@ private fun FleetCommandDeck(
ServerCommandRow( ServerCommandRow(
server = server, server = server,
summary = state.summaries[server.id], summary = state.summaries[server.id],
diagnostics = state.routeDiagnostics[server.id],
onClick = { controller.selectServer(server.id) }, onClick = { controller.selectServer(server.id) },
) )
} }
@@ -346,7 +349,12 @@ private fun CommandFilters(
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) { private fun ServerCommandRow(
server: ServerProfile,
summary: MetricSummary?,
diagnostics: RouteDiagnostics?,
onClick: () -> Unit,
) {
val status = summary?.status ?: ServerStatus.Unknown val status = summary?.status ?: ServerStatus.Unknown
val health = healthScore(summary) val health = healthScore(summary)
Surface( Surface(
@@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status)) LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
if (!server.enabled) LabelCapsule("OFF", StatusUnknown) if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure) if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
RouteBadges(diagnostics)
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) } server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
@@ -423,6 +432,9 @@ private fun ServerCockpit(
item { item {
CockpitHero(server, summary, controller) CockpitHero(server, summary, controller)
} }
item {
RoutePanel(state.routeDiagnostics[server.id])
}
item { item {
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab) CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
} }
@@ -477,6 +489,25 @@ private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controll
} }
} }
@Composable
private fun RoutePanel(diagnostics: RouteDiagnostics?) {
DataPanel("Route") {
if (diagnostics == null) {
Text("No route check has completed yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("Network", diagnostics.activeNetwork)
RuleRow("Route", diagnostics.routeUsed.label)
RuleRow("DNS", diagnostics.dnsResult)
RuleRow("VPN", if (diagnostics.vpnActive) "Active" else "Inactive")
RuleRow("Fallback", if (diagnostics.fallbackUsed) "Used" else "Not used")
RuleRow("Last result", diagnostics.summary)
RuleRow("Checked", diagnostics.timestampMillis.formatTime())
}
}
}
}
@Composable @Composable
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) { private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
Row( Row(
@@ -563,6 +594,7 @@ private fun SettingsCockpit(server: ServerProfile, controller: MonitorController
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%") RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms") RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}") RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", "))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") } Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") } OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
@@ -665,6 +697,16 @@ private fun LabelCapsule(text: String, color: Color) {
} }
} }
@Composable
private fun RouteBadges(diagnostics: RouteDiagnostics?) {
if (diagnostics == null) return
if (diagnostics.vpnActive) LabelCapsule("VPN", StatusWarning)
if (diagnostics.fallbackUsed) LabelCapsule("LAN FALLBACK", StatusOnline)
if (!diagnostics.success && diagnostics.issue != NetworkIssue.None) {
LabelCapsule(diagnostics.issue.label.uppercase(), routeIssueColor(diagnostics.issue))
}
}
@Composable @Composable
private fun HealthBadge(score: Int, status: ServerStatus) { private fun HealthBadge(score: Int, status: ServerStatus) {
val color = colorForStatus(status) val color = colorForStatus(status)
@@ -834,6 +876,7 @@ private fun ServerEditorDialog(
} }
item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } } item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } } item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } }
item { EditorField("Fallback LAN IPs", draft.fallbackIpAddresses) { value -> onChange { it.copy(fallbackIpAddresses = value) } } }
item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } } item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
item { item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1005,6 +1048,19 @@ private fun colorForPercent(value: Double?): Color = when {
else -> StatusOnline else -> StatusOnline
} }
private fun routeIssueColor(issue: NetworkIssue): Color = when (issue) {
NetworkIssue.None -> StatusOnline
NetworkIssue.DnsFailure -> StatusInsecure
NetworkIssue.VpnActive -> StatusWarning
NetworkIssue.LanRouteBlocked -> StatusWarning
NetworkIssue.Timeout -> StatusWarning
NetworkIssue.TlsFailure -> StatusCritical
NetworkIssue.AuthFailure -> StatusCritical
NetworkIssue.ApiFailure -> StatusCritical
NetworkIssue.RateLimited -> StatusWarning
NetworkIssue.Unknown -> StatusUnknown
}
private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--" private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--" private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--" private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"

View File

@@ -0,0 +1,36 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteAttemptPlannerTest {
@Test
fun attempts_usesSystemDnsWhenNoFallbacksOrVpn() {
assertEquals(
listOf(RouteKind.SystemDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = false, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsFallbackDnsWhenAliasesExist() {
assertEquals(
listOf(RouteKind.SystemDns, RouteKind.FallbackDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsLanRoutesWhenVpnAndLanAreAvailable() {
assertEquals(
listOf(
RouteKind.SystemDns,
RouteKind.FallbackDns,
RouteKind.LanSystemDns,
RouteKind.LanFallbackDns,
),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = true, lanAvailable = true),
)
}
}

View File

@@ -24,4 +24,12 @@ class UrlRulesTest {
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"), UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
) )
} }
@Test
fun parseFallbackIps_acceptsCommaAndWhitespaceSeparatedValues() {
assertEquals(
listOf("192.168.1.20", "10.0.0.5"),
UrlRules.parseFallbackIps("192.168.1.20, 10.0.0.5\n192.168.1.20"),
)
}
} }

View File

@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.2.10"
composeBom = "2026.02.01" composeBom = "2026.02.01"
okhttp = "4.12.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -28,6 +29,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }