Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="com.owncloud.android.providers.PERMISSION" />
<queries>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<InetAddress> =
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,7 @@
<string name="share_expires">Share expires on %1$s</string>
<string name="dismiss">Dismiss</string>
<string name="account_not_found">Account not found!</string>
<string name="local_network_permission_denied">Allow nearby devices to reach your local server</string>
<string name="local_network_permission_settings_path">Go to Permissions → Nearby devices → Allow</string>
<string name="local_network_permission_allow">Allow</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Nextcloud Notes - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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(""))
}
}
Loading