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))
    }
}