From 5eb8885249a09863e6ad3d61520f0c62dd90f242 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 15:48:00 +0300 Subject: [PATCH 1/2] feat(permission): access local network Signed-off-by: alperozturk96 --- .../notes/localnetwork/LocalNetworkAddress.kt | 39 +++++++ .../localnetwork/LocalNetworkPermission.kt | 27 +++++ .../LocalNetworkPermissionRequester.kt | 92 ++++++++++++++++ .../owncloud/notes/main/MainActivity.java | 3 + app/src/main/res/values/strings.xml | 3 + .../localnetwork/LocalNetworkAddressTest.kt | 102 ++++++++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddress.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermission.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkPermissionRequester.kt create mode 100644 app/src/test/java/it/niedermann/owncloud/notes/localnetwork/LocalNetworkAddressTest.kt 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("")) + } +} From 9da1759feb5bdaddb5b5ca9acd5d0245ba34f675 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 15:58:06 +0300 Subject: [PATCH 2/2] add permission Signed-off-by: alperozturk96 --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) 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 @@ +