Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<InetAddress> =
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<Network, LinkProperties>()

// 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<RouteInfo> {
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Nextcloud Android Common Library
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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(""))
}
}
Loading