diff --git a/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkAddress.kt b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkAddress.kt new file mode 100644 index 00000000..4c6cdc9b --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkAddress.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.network + +import androidx.annotation.WorkerThread +import java.net.Inet6Address +import java.net.InetAddress +import java.net.URI +import java.util.Locale + +object LocalNetworkAddress { + private const val MDNS_SUFFIX = ".local" + private const val HOST_LABEL_SEPARATOR = '.' + private const val SCHEME_SEPARATOR = "://" + private const val DEFAULT_SCHEME = "https" + + // Mask for the first byte of a private IPv6 address + private const val UNIQUE_LOCAL_MASK = 0xFE + + // Private IPv6 addresses start with fc or fd + private const val UNIQUE_LOCAL_PREFIX = 0xFC + + // Blocks while the host name is looked up, so call it from a background thread + @JvmStatic + @JvmOverloads + @WorkerThread + fun isLocalServer(serverUrl: String, routes: LocalNetworkRoutes? = null): Boolean { + val host = parseHost(serverUrl) ?: return false + val caseInsensitiveHost = host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR) + return caseInsensitiveHost.endsWith(MDNS_SUFFIX) || resolvesToLocalAddress(host, routes) + } + + private fun resolvesToLocalAddress(host: String, routes: LocalNetworkRoutes?): Boolean { + val directlyConnectedRoutes = routes?.directlyConnectedRoutes().orEmpty() + return resolve(host).any { address -> + address.isLocalNetworkAddress() || directlyConnectedRoutes.any { it.matches(address) } + } + } + + // Users can type the server address without http:// or https://, e.g. 192.168.1.10:8080 + private fun parseHost(serverUrl: String): String? { + val url = if (serverUrl.contains(SCHEME_SEPARATOR)) serverUrl else "$DEFAULT_SCHEME$SCHEME_SEPARATOR$serverUrl" + return runCatching { URI(url).host }.getOrNull() + } + + // Looks up all IP addresses of the host name + private fun resolve(host: String): List = + runCatching { InetAddress.getAllByName(host).toList() }.getOrDefault(emptyList()) + + private fun InetAddress.isLocalNetworkAddress(): Boolean = when { + // Loopback (127.0.0.1, ::1) is the device itself, not the local network + isLoopbackAddress -> false + + // Private LAN addresses (192.168.x.x, 10.x.x.x, 172.16.x.x, 169.254.x.x) + isSiteLocalAddress || isLinkLocalAddress -> true + + // Private IPv6 addresses (fc00:: to fdff::), not covered by isSiteLocalAddress + this is Inet6Address -> address.first().toInt() and UNIQUE_LOCAL_MASK == UNIQUE_LOCAL_PREFIX + + // Public internet address + else -> false + } +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkPermission.kt b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkPermission.kt new file mode 100644 index 00000000..b1aa856b --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkPermission.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.network + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat + +object LocalNetworkPermission { + // Android 17 only blocks the local network for apps that target Android 17 or higher + @JvmStatic + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.CINNAMON_BUN) + fun isRequired(context: Context): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && + context.applicationInfo.targetSdkVersion >= Build.VERSION_CODES.CINNAMON_BUN + + @JvmStatic + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + fun isGranted(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkRoutes.kt b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkRoutes.kt new file mode 100644 index 00000000..79a75b7b --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/network/LocalNetworkRoutes.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.network + +import android.Manifest +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.RouteInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import java.util.concurrent.ConcurrentHashMap + +// Finds servers on the same Wi-Fi as the phone, even when their IP is not a private one like 192.168.x.x. +// Create it once per app, it keeps listening to network changes for as long as the app runs. +class LocalNetworkRoutes +@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) +constructor(context: Context) { + // Network settings (IPs, routes) of every Wi-Fi, Ethernet etc. network the phone is connected to + private val linkProperties = ConcurrentHashMap() + + // Keeps linkProperties up to date when a network connects, changes or disconnects + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { + linkProperties[network] = properties + } + + override fun onLost(network: Network) { + linkProperties.remove(network) + } + } + + // Only needed on Android 17+, where the local network is blocked without the permission + init { + val appContext = context.applicationContext + if (LocalNetworkPermission.isRequired(appContext)) { + appContext.getSystemService(ConnectivityManager::class.java) + ?.registerNetworkCallback(buildLocalNetworkRequest(), callback) + } + } + + // IP ranges of the networks the phone is directly on, e.g. 192.168.1.0/24 of the home Wi-Fi + fun directlyConnectedRoutes(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) { + return emptyList() + } + + return linkProperties.values + .flatMap { it.routes } + .filter { it.isDirectlyConnected() } + } + + // Networks Android counts as local; VPNs are left out by default, cellular is not listed + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun buildLocalNetworkRequest(): NetworkRequest = NetworkRequest.Builder() + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_USB) + .addTransportType(NetworkCapabilities.TRANSPORT_BLUETOOTH) + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .build() + + // Route to devices on the same network as the phone, not through the router to the internet + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun RouteInfo.isDirectlyConnected(): Boolean = + type == RouteInfo.RTN_UNICAST && !hasGateway() && !isDefaultRoute +} diff --git a/core/src/test/java/com/nextcloud/android/common/core/network/LocalNetworkAddressTest.kt b/core/src/test/java/com/nextcloud/android/common/core/network/LocalNetworkAddressTest.kt new file mode 100644 index 00000000..800b38be --- /dev/null +++ b/core/src/test/java/com/nextcloud/android/common/core/network/LocalNetworkAddressTest.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.network + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalNetworkAddressTest { + + private fun isLocal(serverUrl: String): Boolean = LocalNetworkAddress.isLocalServer(serverUrl) + + @Test + fun `private class A IPv4 address is local`() { + assertTrue(isLocal("https://10.0.0.5")) + } + + @Test + fun `private class B IPv4 address is local`() { + assertTrue(isLocal("https://172.16.0.1")) + } + + @Test + fun `private class C IPv4 address is local`() { + assertTrue(isLocal("http://192.168.1.10")) + } + + @Test + fun `private IPv4 address with port and path is local`() { + assertTrue(isLocal("https://192.168.1.10:8443/nextcloud")) + } + + @Test + fun `link local IPv4 address is local`() { + assertTrue(isLocal("http://169.254.10.20")) + } + + @Test + fun `link local IPv6 address is local`() { + assertTrue(isLocal("http://[fe80::1]")) + } + + @Test + fun `unique local IPv6 address is local`() { + assertTrue(isLocal("https://[fd12:3456:789a::1]")) + } + + @Test + fun `mdns host name is local`() { + assertTrue(isLocal("https://nextcloud.local")) + } + + @Test + fun `mdns host name is local regardless of case and trailing dot`() { + assertTrue(isLocal("https://NextCloud.LOCAL.")) + } + + @Test + fun `private IPv4 address without scheme is local`() { + assertTrue(isLocal("192.168.1.10")) + } + + @Test + fun `private IPv4 address with port and without scheme is local`() { + assertTrue(isLocal("10.0.2.2:55000")) + } + + @Test + fun `unique local IPv6 address without scheme is local`() { + assertTrue(isLocal("[fd12:3456:789a::1]:443")) + } + + @Test + fun `mdns host name without scheme is local`() { + assertTrue(isLocal("nextcloud.local:8080/nextcloud")) + } + + @Test + fun `public IPv4 address without scheme is not local`() { + assertFalse(isLocal("8.8.8.8:443")) + } + + @Test + fun `IPv4 loopback address is not local`() { + assertFalse(isLocal("http://127.0.0.1")) + } + + @Test + fun `IPv6 loopback address is not local`() { + assertFalse(isLocal("http://[::1]")) + } + + @Test + fun `public IPv4 address is not local`() { + assertFalse(isLocal("https://8.8.8.8")) + } + + @Test + fun `public IPv6 address is not local`() { + assertFalse(isLocal("https://[2001:4860:4860::8888]")) + } + + @Test + fun `IPv4 address next to private range is not local`() { + assertFalse(isLocal("https://172.32.0.1")) + } + + @Test + fun `url without host is not local`() { + assertFalse(isLocal("file:///storage/emulated/0")) + } + + @Test + fun `malformed url is not local`() { + assertFalse(isLocal("https://not a valid url")) + } + + @Test + fun `empty url is not local`() { + assertFalse(isLocal("")) + } +}