diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml
index 446c4606d7..c27910aee3 100644
--- a/libraries/androidutils/src/main/AndroidManifest.xml
+++ b/libraries/androidutils/src/main/AndroidManifest.xml
@@ -8,4 +8,5 @@
+
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt
new file mode 100644
index 0000000000..da84a99a17
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.network
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.InetAddress
+
+interface DnsResolver {
+ suspend fun resolve(host: String): List
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultDnsResolver : DnsResolver {
+ override suspend fun resolve(host: String): List = withContext(Dispatchers.IO) {
+ InetAddress.getAllByName(host).toList()
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt
new file mode 100644
index 0000000000..b26ef49ac0
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.network
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.core.data.tryOrNull
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.URI
+
+sealed interface LocalNetworkClassification {
+ data object PublicIp : LocalNetworkClassification
+ data object LocalIp : LocalNetworkClassification
+ data object Unresolvable : LocalNetworkClassification
+}
+
+interface LocalNetworkAddressClassifier {
+ /**
+ * Classify [url] as pointing to a local network, a public host, or unresolvable.
+ */
+ suspend fun classify(url: String): LocalNetworkClassification
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultLocalNetworkAddressClassifier(
+ private val dnsResolver: DnsResolver,
+) : LocalNetworkAddressClassifier {
+ override suspend fun classify(url: String): LocalNetworkClassification {
+ val host = extractHost(url) ?: return LocalNetworkClassification.Unresolvable
+ // `.local` domains are always link-local by definition
+ if (host.endsWith(".local", ignoreCase = true)) return LocalNetworkClassification.LocalIp
+
+ val resolved = tryOrNull { dnsResolver.resolve(host) }
+
+ if (resolved.isNullOrEmpty()) return LocalNetworkClassification.Unresolvable
+
+ return if (resolved.any { it.isLocalRange() }) {
+ LocalNetworkClassification.LocalIp
+ } else {
+ LocalNetworkClassification.PublicIp
+ }
+ }
+
+ private fun extractHost(url: String): String? {
+ return tryOrNull {
+ val uri = URI(url)
+ uri.host?.takeIf { it.isNotBlank() }
+ }
+ }
+
+ private fun InetAddress.isLocalRange(): Boolean {
+ if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress) return true
+ // Cases not covered by jvm
+ // 1. IPV4 100.64.0.0/10
+ if (this is Inet4Address) {
+ val bytes = address
+ val b0 = bytes[0].toInt() and 0xff
+ val b1 = bytes[1].toInt() and 0xff
+ if (b0 == 100 && b1 in 64..127) return true
+ }
+ // 2. IPV6 fc00::/7
+ if (this is Inet6Address) {
+ val firstByte = address[0].toInt() and 0xff
+ if (firstByte and 0xfe == 0xfc) return true
+ }
+ return false
+ }
+}
diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt
new file mode 100644
index 0000000000..5015abc307
--- /dev/null
+++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.network
+
+import io.element.android.libraries.core.data.tryOrNull
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+class FakeDnsResolver(
+ private val results: Map> = emptyMap(),
+ private val throwOnUnknown: Boolean = true,
+) : DnsResolver {
+ override suspend fun resolve(host: String): List {
+ // Mimic production DefaultDnsResolver: InetAddress resolves IP literals synchronously
+ // (no DNS lookup), so tests can pass literals through without registering them in [results].
+ if (looksLikeIpLiteral(host)) {
+ tryOrNull { InetAddress.getAllByName(host).toList() }?.let { return it }
+ }
+ return results[host]
+ ?: if (throwOnUnknown) throw UnknownHostException(host) else emptyList()
+ }
+
+ private fun looksLikeIpLiteral(host: String): Boolean {
+ if (':' in host) return true
+ val parts = host.split('.')
+ return parts.size == 4 && parts.all { it.toIntOrNull() != null }
+ }
+}
diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt
new file mode 100644
index 0000000000..4c567822b2
--- /dev/null
+++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.network
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import java.net.InetAddress
+
+class LocalNetworkAddressClassifierTest {
+ private fun classifier(
+ resolver: DnsResolver = FakeDnsResolver(),
+ ) = DefaultLocalNetworkAddressClassifier(resolver)
+
+ @Test
+ fun `IPv4 loopback literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://127.0.0.1:8008")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 RFC1918 10 literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://10.0.0.5")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 RFC1918 172_16 literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://172.20.1.2")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 RFC1918 192_168 literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://192.168.1.10")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 link local literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://169.254.10.20")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 CGNAT 100_64_0_0_10 literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://100.100.0.1")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv4 public literal is PublicIp`() = runTest {
+ assertThat(classifier().classify("https://8.8.8.8")).isEqualTo(LocalNetworkClassification.PublicIp)
+ }
+
+ @Test
+ fun `IPv6 loopback literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://[::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv6 link local literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://[fe80::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv6 unique local literal is LocalIp`() = runTest {
+ assertThat(classifier().classify("https://[fc00::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `IPv6 public literal is PublicIp`() = runTest {
+ assertThat(classifier().classify("https://[2606:4700:4700::1111]")).isEqualTo(LocalNetworkClassification.PublicIp)
+ }
+
+ @Test
+ fun `dot local mDNS name is LocalIp without DNS`() = runTest {
+ assertThat(classifier().classify("https://matrix.local"))
+ .isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `hostname resolving to public IP is PublicIp`() = runTest {
+ val resolver = FakeDnsResolver(
+ results = mapOf("matrix.org" to listOf(InetAddress.getByName("8.8.8.8")))
+ )
+ assertThat(classifier(resolver).classify("https://matrix.org"))
+ .isEqualTo(LocalNetworkClassification.PublicIp)
+ }
+
+ @Test
+ fun `hostname resolving to private IP is LocalIp`() = runTest {
+ val resolver = FakeDnsResolver(
+ results = mapOf("matrix.corp.internal" to listOf(InetAddress.getByName("10.0.0.5")))
+ )
+ assertThat(classifier(resolver).classify("https://matrix.corp.internal"))
+ .isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `hostname with mixed public and private addresses is LocalIp`() = runTest {
+ val resolver = FakeDnsResolver(
+ results = mapOf(
+ "matrix.example" to listOf(
+ InetAddress.getByName("8.8.8.8"),
+ InetAddress.getByName("10.0.0.5"),
+ )
+ )
+ )
+ assertThat(classifier(resolver).classify("https://matrix.example"))
+ .isEqualTo(LocalNetworkClassification.LocalIp)
+ }
+
+ @Test
+ fun `unresolvable hostname is Unresolvable`() = runTest {
+ val resolver = FakeDnsResolver(throwOnUnknown = true)
+ assertThat(classifier(resolver).classify("https://nonexistent.example"))
+ .isEqualTo(LocalNetworkClassification.Unresolvable)
+ }
+
+ @Test
+ fun `malformed URL is Unresolvable`() = runTest {
+ assertThat(classifier().classify("not a url")).isEqualTo(LocalNetworkClassification.Unresolvable)
+ }
+}
diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt
new file mode 100644
index 0000000000..7ed46a63e1
--- /dev/null
+++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.permissions.api
+
+interface LocalNetworkPermissionAdvisor {
+ /**
+ * Returns true when the app should request the ACCESS_LOCAL_NETWORK permission before making
+ * network requests to [homeserverUrl].
+ */
+ suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean
+}
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt
new file mode 100644
index 0000000000..a34ece0859
--- /dev/null
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.permissions.impl
+
+import android.Manifest.permission.ACCESS_LOCAL_NETWORK
+import android.os.Build
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
+import io.element.android.libraries.androidutils.network.LocalNetworkClassification
+import io.element.android.libraries.permissions.api.LocalNetworkPermissionAdvisor
+import io.element.android.libraries.permissions.api.PermissionStateProvider
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
+
+@ContributesBinding(AppScope::class)
+class DefaultLocalNetworkPermissionAdvisor(
+ private val classifier: LocalNetworkAddressClassifier,
+ private val permissionStateProvider: PermissionStateProvider,
+ private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
+) : LocalNetworkPermissionAdvisor {
+ override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean {
+ if (!buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.CINNAMON_BUN)) return false
+ if (permissionStateProvider.isPermissionGranted(ACCESS_LOCAL_NETWORK)) return false
+ return when (classifier.classify(homeserverUrl)) {
+ LocalNetworkClassification.LocalIp -> true
+ LocalNetworkClassification.PublicIp,
+ // Unresolvable: we can't confirm the host is local, so we don't prompt, acceptable trade-off vs. over-prompting.
+ LocalNetworkClassification.Unresolvable -> false
+ }
+ }
+}
diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt
new file mode 100644
index 0000000000..2ed1dd7548
--- /dev/null
+++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.permissions.impl
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
+import io.element.android.libraries.androidutils.network.LocalNetworkClassification
+import io.element.android.libraries.permissions.test.FakePermissionStateProvider
+import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+private const val LOCAL_URL = "https://matrix.corp.internal"
+private const val PUBLIC_URL = "https://matrix.org"
+
+class DefaultLocalNetworkPermissionAdvisorTest {
+ private fun advisor(
+ sdkInt: Int,
+ permissionGranted: Boolean,
+ classification: LocalNetworkClassification,
+ ) = DefaultLocalNetworkPermissionAdvisor(
+ classifier = FakeLocalNetworkAddressClassifier(classification),
+ permissionStateProvider = FakePermissionStateProvider(permissionGranted = permissionGranted),
+ buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt),
+ )
+
+ @Test
+ fun `returns false when SDK below 37`() = runTest {
+ val result = advisor(sdkInt = 36, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
+ .shouldRequestPermissionFor(LOCAL_URL)
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `returns false when permission already granted`() = runTest {
+ val result = advisor(sdkInt = 37, permissionGranted = true, classification = LocalNetworkClassification.LocalIp)
+ .shouldRequestPermissionFor(LOCAL_URL)
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `returns false when URL classified as public IP`() = runTest {
+ val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.PublicIp)
+ .shouldRequestPermissionFor(PUBLIC_URL)
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `returns true when SDK 37, permission missing, URL is local`() = runTest {
+ val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
+ .shouldRequestPermissionFor(LOCAL_URL)
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `returns false when SDK 37, permission missing, URL unresolvable`() = runTest {
+ val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.Unresolvable)
+ .shouldRequestPermissionFor(LOCAL_URL)
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `returns true when SDK above 37, permission missing, URL is local`() = runTest {
+ val result = advisor(sdkInt = 38, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
+ .shouldRequestPermissionFor(LOCAL_URL)
+ assertThat(result).isTrue()
+ }
+}
+
+private class FakeLocalNetworkAddressClassifier(
+ private val classification: LocalNetworkClassification,
+) : LocalNetworkAddressClassifier {
+ override suspend fun classify(url: String): LocalNetworkClassification = classification
+}
diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt
new file mode 100644
index 0000000000..1a3363d6dc
--- /dev/null
+++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.permissions.test
+
+import io.element.android.libraries.permissions.api.LocalNetworkPermissionAdvisor
+
+class FakeLocalNetworkPermissionAdvisor(
+ private val shouldPrompt: Boolean = false,
+) : LocalNetworkPermissionAdvisor {
+ override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean = shouldPrompt
+}