LocalNetworkAccess.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.taler.merchantpos.config
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import java.io.IOException
import java.net.InetAddress
import java.net.URI
import java.net.UnknownHostException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
internal class LocalNetworkAccessDenied : IOException("Local network access is required")
/** Owned by the activity ViewModel: permission requests survive activity recreation. */
class LocalNetworkAccess internal constructor(
private val sdk: Int,
private val granted: () -> Boolean,
private val isLocal: suspend (String) -> Boolean,
)
{
constructor(context: Context) : this(
Build.VERSION.SDK_INT,
{ ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED },
{ host ->
withContext(Dispatchers.IO) {
isLocalMerchantHost(host) { address ->
val manager = context.getSystemService(ConnectivityManager::class.java)
val network = manager.activeNetwork
val capabilities = manager.getNetworkCapabilities(network)
val lan =
capabilities != null &&
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) &&
(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
lan &&
manager.getLinkProperties(network)?.routes.orEmpty().any { route ->
route.destination.prefixLength > 0 && (route.gateway?.isAnyLocalAddress != false) &&
route.matches(address)
}
}
}
},
)
var host by mutableStateOf<String?>(null)
private set
var permissionResults by mutableIntStateOf(0)
private set
private var pending: CompletableDeferred<Boolean>? = null
private var waiters = 0
private var dismissed = false
fun hasAccess(): Boolean = sdk < 37 || granted()
/** A deliberate new setup attempt may show the explanation again. */
fun retry()
{
dismissed = false
}
suspend fun ensureAccess(url: String)
{
if (hasAccess()) return
val hostname = runCatching { URI(url).host?.removeSurrounding("[", "]") }.getOrNull() ?: return
if (!isLocal(hostname)) return
withContext(Dispatchers.Main.immediate) {
if (hasAccess()) return@withContext
if (dismissed) throw LocalNetworkAccessDenied()
val request =
pending ?: CompletableDeferred<Boolean>().also {
pending = it
host = hostname
}
waiters++
try
{
if (!request.await()) throw LocalNetworkAccessDenied()
}
finally
{
waiters--
if (waiters == 0 && pending === request)
{
pending = null
host = null
}
}
}
}
fun permissionResult()
{
permissionResults++
if (hasAccess())
{
pending?.complete(true)
host = null
dismissed = false
}
}
fun dismiss()
{
dismissed = true
pending?.complete(false)
host = null
}
}
/** Check names before DNS because resolving .local itself requires LAN permission. */
internal fun isLocalMerchantHost(
host: String,
resolve: (String) -> Array<InetAddress> = InetAddress::getAllByName,
directlyConnected: (InetAddress) -> Boolean = { false },
): Boolean
{
val name = host.removeSurrounding("[", "]").trimEnd('.').lowercase()
if (name == "localhost" || name.endsWith(".localhost")) return false
if (name.endsWith(".local") || ('.' !in name && ':' !in name)) return true
val addresses =
try
{
resolve(name)
}
catch (_: UnknownHostException)
{
// Let the normal connection report an invalid or unavailable public hostname.
return false
}
return addresses.any { address ->
val bytes = address.address.map { it.toInt() and 0xff }
!address.isLoopbackAddress &&
(address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress ||
(bytes.size == 4 && bytes[0] == 100 && bytes[1] in 64..127) ||
(bytes.size == 4 && bytes.all { it == 255 }) ||
(bytes.size == 16 && bytes[0] and 0xfe == 0xfc) || directlyConnected(address))
}
}