diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 111a0dc01..22782d05c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddress.kt b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddress.kt
new file mode 100644
index 000000000..8c877d7a5
--- /dev/null
+++ b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddress.kt
@@ -0,0 +1,39 @@
+/*
+ * Nextcloud Notes - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.niedermann.owncloud.notes.localnetwork
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+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 UNIQUE_LOCAL_MASK = 0xFE
+ private const val UNIQUE_LOCAL_PREFIX = 0xFC
+
+ suspend fun isLocalServer(serverUrl: String): Boolean {
+ val host = runCatching { URI(serverUrl).host }.getOrNull() ?: return false
+ return host.isMdnsHost() || withContext(Dispatchers.IO) { resolve(host).any { it.isLocalNetworkAddress() } }
+ }
+
+ private fun String.isMdnsHost(): Boolean =
+ lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR).endsWith(MDNS_SUFFIX)
+
+ private fun resolve(host: String): List =
+ runCatching { InetAddress.getAllByName(host).toList() }.getOrDefault(emptyList())
+
+ private fun InetAddress.isLocalNetworkAddress(): Boolean = when {
+ isLoopbackAddress -> false
+ isSiteLocalAddress || isLinkLocalAddress -> true
+ this is Inet6Address -> address.first().toInt() and UNIQUE_LOCAL_MASK == UNIQUE_LOCAL_PREFIX
+ else -> false
+ }
+}
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermission.kt b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermission.kt
new file mode 100644
index 000000000..2d786daa2
--- /dev/null
+++ b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermission.kt
@@ -0,0 +1,27 @@
+/*
+ * Nextcloud Notes - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.niedermann.owncloud.notes.localnetwork
+
+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 {
+ @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
+
+ @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/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermissionRequester.kt b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermissionRequester.kt
new file mode 100644
index 000000000..ae75d6be2
--- /dev/null
+++ b/app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermissionRequester.kt
@@ -0,0 +1,92 @@
+/*
+ * Nextcloud Notes - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.niedermann.owncloud.notes.localnetwork
+
+import android.Manifest
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import it.niedermann.owncloud.notes.R
+import it.niedermann.owncloud.notes.branding.BrandedSnackbar
+import kotlinx.coroutines.launch
+
+class LocalNetworkPermissionRequester(private val activity: AppCompatActivity) {
+ private val permissionLauncher =
+ activity.registerForActivityResult(ActivityResultContracts.RequestPermission(), ::onPermissionResult)
+
+ fun requestIfNeeded(serverUrl: String) {
+ if (!LocalNetworkPermission.isRequired(activity)) {
+ return
+ }
+
+ requestForLocalServer(serverUrl)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun requestForLocalServer(serverUrl: String) {
+ if (LocalNetworkPermission.isGranted(activity)) {
+ return
+ }
+
+ activity.lifecycleScope.launch {
+ if (LocalNetworkAddress.isLocalServer(serverUrl)) {
+ launchRequest()
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun launchRequest() {
+ permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
+ }
+
+ private fun onPermissionResult(isGranted: Boolean) {
+ if (isGranted || Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) {
+ return
+ }
+
+ showDeniedSnackbar()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun showDeniedSnackbar() {
+ val canShowSystemDialog =
+ activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)
+ val message = if (canShowSystemDialog) {
+ R.string.local_network_permission_denied
+ } else {
+ R.string.local_network_permission_settings_path
+ }
+ val snackbar = BrandedSnackbar.make(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG)
+
+ if (canShowSystemDialog) {
+ snackbar.setAction(R.string.local_network_permission_allow) { launchRequest() }
+ } else {
+ snackbar.setAction(R.string.action_settings) { openAppSettings() }
+ }
+
+ snackbar.show()
+ }
+
+ private fun openAppSettings() {
+ val intent = Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts(PACKAGE_SCHEME, activity.packageName, null)
+ )
+ activity.startActivity(intent)
+ }
+
+ companion object {
+ private const val PACKAGE_SCHEME = "package"
+ }
+}
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
index 2f8fe3855..05a8ee732 100644
--- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
+++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
@@ -101,6 +101,7 @@
import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment;
import it.niedermann.owncloud.notes.exception.IntendedOfflineException;
import it.niedermann.owncloud.notes.importaccount.ImportAccountActivity;
+import it.niedermann.owncloud.notes.localnetwork.LocalNetworkPermissionRequester;
import it.niedermann.owncloud.notes.main.items.ItemAdapter;
import it.niedermann.owncloud.notes.main.items.grid.GridItemDecoration;
import it.niedermann.owncloud.notes.main.items.list.NotesListViewItemTouchHelper;
@@ -134,6 +135,7 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A
protected MainViewModel mainViewModel;
private EcosystemManager ecosystemManager;
+ private final LocalNetworkPermissionRequester localNetworkPermission = new LocalNetworkPermissionRequester(this);
private boolean gridView = true;
@@ -301,6 +303,7 @@ protected void onCreate(Bundle savedInstanceState) {
mainViewModel.getNavigationCategories().observe(this, navigationItems -> this.adapterCategories.setItems(navigationItems));
mainViewModel.getCurrentAccount().observe(this, (nextAccount) -> {
fabCreate.hide();
+ localNetworkPermission.requestIfNeeded(nextAccount.getUrl());
Glide
.with(this)
.load(nextAccount.getUrl() + "/index.php/avatar/" + Uri.encode(nextAccount.getUserName()) + "/64")
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9dd008cac..ec428eace 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -546,4 +546,7 @@
Share expires on %1$s
Dismiss
Account not found!
+ Allow nearby devices to reach your local server
+ Go to Permissions → Nearby devices → Allow
+ Allow
diff --git a/app/src/test/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddressTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddressTest.kt
new file mode 100644
index 000000000..c3a710e85
--- /dev/null
+++ b/app/src/test/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddressTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Nextcloud Notes - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.niedermann.owncloud.notes.localnetwork
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalNetworkAddressTest {
+
+ private fun isLocal(serverUrl: String): Boolean = runBlocking { 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 `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(""))
+ }
+}