From f7a98a5aaa2b8b8790b7143f137fe7c3927e9757 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 13:11:03 +0300 Subject: [PATCH 01/13] add local network permission check Signed-off-by: alperozturk96 --- .../client/network/LocalNetworkAddress.kt | 43 +++++++ .../LocalNetworkPermissionRequester.kt | 116 ++++++++++++++++++ .../authentication/AuthenticatorActivity.kt | 15 ++- .../ui/activity/FileDisplayActivity.kt | 3 + .../owncloud/android/utils/PermissionUtil.kt | 10 ++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt create mode 100644 app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt new file mode 100644 index 000000000000..69240bad0f96 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network + +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 + + if (host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR).endsWith(MDNS_SUFFIX)) { + return true + } + + return withContext(Dispatchers.IO) { + resolve(host).any { it.isLocalNetworkAddress() } + } + } + + 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/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt new file mode 100644 index 000000000000..d42c41978fa0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt @@ -0,0 +1,116 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network + +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.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.utils.SnackbarUtil +import com.owncloud.android.R +import com.owncloud.android.utils.PermissionUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.launch + +class LocalNetworkPermissionRequester( + private val activity: AppCompatActivity, + private val viewThemeUtils: () -> ViewThemeUtils +) { + private var pendingResult: ((Boolean) -> Unit)? = null + + private val permissionLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission(), ::deliverResult) + + private val appName: String + get() = activity.getString(R.string.app_name) + + fun requestIfNeeded(serverUrls: List, onGranted: () -> Unit = {}, onDenied: () -> Unit = {}) { + if (!PermissionUtil.isLocalNetworkPermissionRequired(activity)) { + onGranted() + return + } + + requestForLocalServers(serverUrls, onGranted, onDenied) + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun requestForLocalServers(serverUrls: List, onGranted: () -> Unit, onDenied: () -> Unit) { + if (PermissionUtil.hasLocalNetworkPermission(activity)) { + onGranted() + return + } + + activity.lifecycleScope.launch { + if (serverUrls.none { LocalNetworkAddress.isLocalServer(it) }) { + onGranted() + return@launch + } + + pendingResult = { isGranted -> + if (isGranted) { + onGranted() + } else { + showDeniedSnackbar() + onDenied() + } + } + + if (activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)) { + showRationale() + } else { + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + } + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun showRationale() { + val builder = MaterialAlertDialogBuilder(activity) + .setTitle(R.string.local_network_permission_title) + .setMessage(activity.getString(R.string.local_network_permission_rationale, appName)) + .setPositiveButton(R.string.common_ok) { _, _ -> + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + .setNegativeButton(R.string.common_cancel) { _, _ -> deliverResult(isGranted = false) } + .setOnCancelListener { deliverResult(isGranted = false) } + + viewThemeUtils().dialog.colorMaterialAlertDialogBackground(activity, builder) + builder.show() + } + + private fun deliverResult(isGranted: Boolean) { + val callback = pendingResult ?: return + pendingResult = null + callback(isGranted) + } + + private fun showDeniedSnackbar() { + val message = activity.getString(R.string.local_network_permission_denied, appName) + SnackbarUtil.create(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG) + ?.setAction(R.string.actionbar_settings) { openAppSettings() } + ?.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/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index 2ef83e482af3..89a6a9ae2954 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -53,6 +53,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.LocalNetworkPermissionRequester import com.nextcloud.client.onboarding.FirstRunActivity import com.nextcloud.client.onboarding.OnboardingService import com.nextcloud.client.preferences.AppPreferences @@ -157,6 +158,9 @@ open class AuthenticatorActivity : private val statusMapper by lazy { AuthenticatorStatusMapper(resources) } private val accountStore by lazy { AuthenticatorAccountStore(this, AccountManager.get(this), accountManager) } private val loginFlow by lazy { LoginFlowV2(clientRepository, lifecycleScope, ::completeLoginFlow) } + private val localNetworkPermission = LocalNetworkPermissionRequester(this) { + viewThemeUtilsFactory.withDefaultSchemes() + } private var authenticatorAction = ACTION_CREATE private var account: Account? = null @@ -519,6 +523,10 @@ open class AuthenticatorActivity : } Log_OC.d(TAG, "Login flow v2: requesting login URL from $url") + localNetworkPermission.requestIfNeeded(listOf(url), onGranted = { requestLoginUrl(url) }) + } + + private fun requestLoginUrl(url: String) { lifecycleScope.launch { val response = loginFlow.requestLoginResponse(url) if (response.isEmpty()) { @@ -659,7 +667,12 @@ open class AuthenticatorActivity : uri = toAsciiDomainOrKeep(normalizeSchemeOrKeep(uri)) - if (binding != null) { + val serverUri = uri + localNetworkPermission.requestIfNeeded(listOf(serverUri), onGranted = { startServerCheck(serverUri) }) + } + + private fun startServerCheck(uri: String) { + if (accountSetupBinding != null) { serverStatus = AuthenticatorStatus(R.drawable.progress_small, getString(R.string.auth_testing_connection)) showServerStatus() } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 991f623dba03..e67d81f4ef66 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -63,6 +63,7 @@ import com.nextcloud.client.files.DeepLinkHandler import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.network.LocalNetworkPermissionRequester import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.audio.AudioPlayerLauncher import com.nextcloud.client.preferences.AppPreferences @@ -215,6 +216,7 @@ class FileDisplayActivity : private lateinit var folderRefreshScheduler: FolderRefreshScheduler private val dialogs by lazy { FileDisplayDialogs(this, viewThemeUtils) } + private val localNetworkPermission = LocalNetworkPermissionRequester(this) { viewThemeUtils } private val uploadHandler by lazy { FileDisplayUploadHandler(this, connectivityService, fileDataStorageManager) } private val syncReceiver = LocalIntentReceiver(::onSyncBroadcastReceived) @@ -302,6 +304,7 @@ class FileDisplayActivity : if (savedInstanceState == null) { addInitialListOfFilesFragment() + localNetworkPermission.requestIfNeeded(userAccountManager.allUsers.map { it.server.uri.toString() }) if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { handleNavigationIntent(intent) diff --git a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt index dd062ee63b67..ee8895e1bb07 100644 --- a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt @@ -18,6 +18,7 @@ import android.content.pm.ResolveInfo import android.os.Build import android.os.Environment import android.provider.Settings +import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat @@ -47,6 +48,15 @@ object PermissionUtil { fun checkPermissions(context: Context, permissions: Array): Boolean = permissions.all { checkSelfPermission(context, it) } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.CINNAMON_BUN) + fun isLocalNetworkPermissionRequired(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 hasLocalNetworkPermission(context: Context): Boolean = + checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) // endregion /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02ad7b2f9b0c..eb5dd2cfdd96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1116,6 +1116,9 @@ Allow %1$s to access your Nextcloud account %2$s? Deny Allow + Access to local network + %1$s needs the Nearby devices permission to connect to your server on the local network. + %1$s can\'t connect to your server on the local network without the Nearby devices permission. Note to recipient Could not send note Note From 7a41c186264bb2dbf03c0cba8a940ae3e3fa3f57 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 14:03:00 +0300 Subject: [PATCH 02/13] handle missing local network Signed-off-by: alperozturk96 --- .../network/LocalNetworkPermissionHandler.kt | 113 ++++++++++++++++++ .../LocalNetworkPermissionRequester.kt | 4 +- .../java/com/owncloud/android/MainApp.java | 4 + .../authentication/AuthenticatorActivity.kt | 12 +- .../ui/activity/FileDisplayActivity.kt | 20 +++- app/src/main/res/values/strings.xml | 1 + 6 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt new file mode 100644 index 000000000000..84d4cb480e19 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.di.ApplicationScope +import com.owncloud.android.R +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.FilesSyncHelper +import com.owncloud.android.utils.PermissionUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LocalNetworkPermissionHandler @Inject constructor( + private val context: Context, + private val accountManager: UserAccountManager, + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val notificationManager: NotificationManager, + @param:ApplicationScope private val scope: CoroutineScope +) { + companion object { + private const val TAG = "LocalNetworkPermissionHandler" + private const val NOTIFICATION_ID = 1717 + const val ACTION_REQUEST_PERMISSION = "com.nextcloud.client.network.REQUEST_LOCAL_NETWORK_PERMISSION" + } + + val serverUrls: List + get() = accountManager.allUsers.map { it.server.uri.toString() } + + fun notifyIfPermissionMissing() { + if (!PermissionUtil.isLocalNetworkPermissionRequired(context)) { + return + } + + notifyIfLocalServerUnreachable() + } + + fun onPermissionGranted() { + Log_OC.d(TAG, "local network permission granted, restarting failed uploads") + notificationManager.cancel(NOTIFICATION_ID) + FilesSyncHelper.restartUploadsIfNeeded( + uploadsStorageManager, + accountManager, + connectivityService, + powerManagementService + ) + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun notifyIfLocalServerUnreachable() { + if (PermissionUtil.hasLocalNetworkPermission(context)) { + notificationManager.cancel(NOTIFICATION_ID) + return + } + + scope.launch { + if (serverUrls.none { LocalNetworkAddress.isLocalServer(it) }) { + return@launch + } + + Log_OC.w(TAG, "local network permission missing, background operations can't reach the server") + notificationManager.notify(NOTIFICATION_ID, buildNotification()) + } + } + + private fun buildNotification(): Notification { + val appName = context.getString(R.string.app_name) + val message = context.getString(R.string.local_network_permission_notification, appName) + + return NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_GENERAL) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(context.getString(R.string.local_network_permission_title)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setContentIntent(buildContentIntent()) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + } + + private fun buildContentIntent(): PendingIntent { + val intent = Intent(context, FileDisplayActivity::class.java).apply { + action = ACTION_REQUEST_PERMISSION + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + return PendingIntent.getActivity( + context, + NOTIFICATION_ID, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt index d42c41978fa0..62bef8b66346 100644 --- a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt @@ -25,7 +25,8 @@ import kotlinx.coroutines.launch class LocalNetworkPermissionRequester( private val activity: AppCompatActivity, - private val viewThemeUtils: () -> ViewThemeUtils + private val viewThemeUtils: () -> ViewThemeUtils, + private val onPermissionGranted: () -> Unit ) { private var pendingResult: ((Boolean) -> Unit)? = null @@ -59,6 +60,7 @@ class LocalNetworkPermissionRequester( pendingResult = { isGranted -> if (isGranted) { + onPermissionGranted() onGranted() } else { showDeniedSnackbar() diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index 0454dc11a200..986ba5c22299 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -52,6 +52,7 @@ import com.nextcloud.client.logger.Logger; import com.nextcloud.client.migrations.MigrationsManager; import com.nextcloud.client.network.ConnectivityService; +import com.nextcloud.client.network.LocalNetworkPermissionHandler; import com.nextcloud.client.network.NetworkChangeListener; import com.nextcloud.client.network.WalledCheckCache; import com.nextcloud.client.onboarding.OnboardingService; @@ -194,6 +195,8 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC @Inject WalledCheckCache walledCheckCache; + @Inject LocalNetworkPermissionHandler localNetworkPermissionHandler; + @Inject ComposeProcessTextAlias composeProcessTextAlias; // workaround because injection is initialized on onAttachBaseContext @@ -351,6 +354,7 @@ public void onCreate() { walledCheckCache); initContactsBackup(accountManager, backgroundJobManager); notificationChannels(); + localNetworkPermissionHandler.notifyIfPermissionMissing(); if (backgroundJobManager != null) { backgroundJobManager.scheduleMediaFoldersDetectionJob(); diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index 89a6a9ae2954..ad9647ec8b7b 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -53,6 +53,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.LocalNetworkPermissionHandler import com.nextcloud.client.network.LocalNetworkPermissionRequester import com.nextcloud.client.onboarding.FirstRunActivity import com.nextcloud.client.onboarding.OnboardingService @@ -146,6 +147,9 @@ open class AuthenticatorActivity : @Inject lateinit var clientRepository: ClientRepository + @Inject + lateinit var localNetworkPermissionHandler: LocalNetworkPermissionHandler + @get:VisibleForTesting var accountSetupBinding: AccountSetupBinding? = null private set @@ -158,9 +162,11 @@ open class AuthenticatorActivity : private val statusMapper by lazy { AuthenticatorStatusMapper(resources) } private val accountStore by lazy { AuthenticatorAccountStore(this, AccountManager.get(this), accountManager) } private val loginFlow by lazy { LoginFlowV2(clientRepository, lifecycleScope, ::completeLoginFlow) } - private val localNetworkPermission = LocalNetworkPermissionRequester(this) { - viewThemeUtilsFactory.withDefaultSchemes() - } + private val localNetworkPermission = LocalNetworkPermissionRequester( + activity = this, + viewThemeUtils = { viewThemeUtilsFactory.withDefaultSchemes() }, + onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } + ) private var authenticatorAction = ACTION_CREATE private var account: Account? = null diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index e67d81f4ef66..d2697a0df629 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -63,6 +63,7 @@ import com.nextcloud.client.files.DeepLinkHandler import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster +import com.nextcloud.client.network.LocalNetworkPermissionHandler import com.nextcloud.client.network.LocalNetworkPermissionRequester import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.audio.AudioPlayerLauncher @@ -209,6 +210,9 @@ class FileDisplayActivity : @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var localNetworkPermissionHandler: LocalNetworkPermissionHandler + private val viewModel by viewModels { viewModelFactory } private lateinit var binding: FilesBinding @@ -216,7 +220,11 @@ class FileDisplayActivity : private lateinit var folderRefreshScheduler: FolderRefreshScheduler private val dialogs by lazy { FileDisplayDialogs(this, viewThemeUtils) } - private val localNetworkPermission = LocalNetworkPermissionRequester(this) { viewThemeUtils } + private val localNetworkPermission = LocalNetworkPermissionRequester( + activity = this, + viewThemeUtils = { viewThemeUtils }, + onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } + ) private val uploadHandler by lazy { FileDisplayUploadHandler(this, connectivityService, fileDataStorageManager) } private val syncReceiver = LocalIntentReceiver(::onSyncBroadcastReceived) @@ -304,7 +312,7 @@ class FileDisplayActivity : if (savedInstanceState == null) { addInitialListOfFilesFragment() - localNetworkPermission.requestIfNeeded(userAccountManager.allUsers.map { it.server.uri.toString() }) + requestLocalNetworkPermissionIfNeeded() if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) { handleNavigationIntent(intent) @@ -688,6 +696,14 @@ class FileDisplayActivity : handleNavigationIntent(intent) restartIfRequested(intent) handleEcosystemAccountIntent(intent) + + if (intent.action == LocalNetworkPermissionHandler.ACTION_REQUEST_PERMISSION) { + requestLocalNetworkPermissionIfNeeded() + } + } + + private fun requestLocalNetworkPermissionIfNeeded() { + localNetworkPermission.requestIfNeeded(localNetworkPermissionHandler.serverUrls) } @SuppressLint("UnsafeIntentLaunch") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb5dd2cfdd96..36dcd84ff95d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,6 +1119,7 @@ Access to local network %1$s needs the Nearby devices permission to connect to your server on the local network. %1$s can\'t connect to your server on the local network without the Nearby devices permission. + Uploads, downloads and backups can\'t reach your server on the local network. Tap to allow %1$s to access nearby devices. Note to recipient Could not send note Note From e574023d2d27301d59b97c29d36b0dad2fa46138 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 14:19:02 +0300 Subject: [PATCH 03/13] move new classes to under local package and add tests Signed-off-by: alperozturk96 --- .../{ => local}/LocalNetworkAddress.kt | 2 +- .../LocalNetworkPermissionHandler.kt | 3 +- .../LocalNetworkPermissionRequester.kt | 2 +- .../java/com/owncloud/android/MainApp.java | 2 +- .../authentication/AuthenticatorActivity.kt | 4 +- .../ui/activity/FileDisplayActivity.kt | 4 +- .../network/local/LocalNetworkAddressTest.kt | 102 ++++++++++++++++++ 7 files changed, 111 insertions(+), 8 deletions(-) rename app/src/main/java/com/nextcloud/client/network/{ => local}/LocalNetworkAddress.kt (97%) rename app/src/main/java/com/nextcloud/client/network/{ => local}/LocalNetworkPermissionHandler.kt (97%) rename app/src/main/java/com/nextcloud/client/network/{ => local}/LocalNetworkPermissionRequester.kt (98%) create mode 100644 app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt similarity index 97% rename from app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt rename to app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 69240bad0f96..39b7282873a8 100644 --- a/app/src/main/java/com/nextcloud/client/network/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.client.network +package com.nextcloud.client.network.local import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt similarity index 97% rename from app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt rename to app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt index 84d4cb480e19..b91db248b68e 100644 --- a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionHandler.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.client.network +package com.nextcloud.client.network.local import android.app.Notification import android.app.NotificationManager @@ -17,6 +17,7 @@ import androidx.core.app.NotificationCompat import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.di.ApplicationScope +import com.nextcloud.client.network.ConnectivityService import com.owncloud.android.R import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.lib.common.utils.Log_OC diff --git a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt similarity index 98% rename from app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt rename to app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index 62bef8b66346..acec5e3cd11d 100644 --- a/app/src/main/java/com/nextcloud/client/network/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.client.network +package com.nextcloud.client.network.local import android.Manifest import android.content.Intent diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index 986ba5c22299..17aae9712ae6 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -52,7 +52,7 @@ import com.nextcloud.client.logger.Logger; import com.nextcloud.client.migrations.MigrationsManager; import com.nextcloud.client.network.ConnectivityService; -import com.nextcloud.client.network.LocalNetworkPermissionHandler; +import com.nextcloud.client.network.local.LocalNetworkPermissionHandler; import com.nextcloud.client.network.NetworkChangeListener; import com.nextcloud.client.network.WalledCheckCache; import com.nextcloud.client.onboarding.OnboardingService; diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index ad9647ec8b7b..5f1cd0be2afc 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -53,8 +53,8 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable -import com.nextcloud.client.network.LocalNetworkPermissionHandler -import com.nextcloud.client.network.LocalNetworkPermissionRequester +import com.nextcloud.client.network.local.LocalNetworkPermissionHandler +import com.nextcloud.client.network.local.LocalNetworkPermissionRequester import com.nextcloud.client.onboarding.FirstRunActivity import com.nextcloud.client.onboarding.OnboardingService import com.nextcloud.client.preferences.AppPreferences diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index d2697a0df629..8008e698bfa3 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -63,8 +63,8 @@ import com.nextcloud.client.files.DeepLinkHandler import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster -import com.nextcloud.client.network.LocalNetworkPermissionHandler -import com.nextcloud.client.network.LocalNetworkPermissionRequester +import com.nextcloud.client.network.local.LocalNetworkPermissionHandler +import com.nextcloud.client.network.local.LocalNetworkPermissionRequester import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.audio.AudioPlayerLauncher import com.nextcloud.client.preferences.AppPreferences diff --git a/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt b/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt new file mode 100644 index 000000000000..631820b803db --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt @@ -0,0 +1,102 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network.local + +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 44af79e8fc770c35998a5d11cacfdd17b069cb7a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 14:25:07 +0300 Subject: [PATCH 04/13] show native dialog Signed-off-by: alperozturk96 --- app/src/main/AndroidManifest.xml | 1 + .../local/LocalNetworkPermissionRequester.kt | 60 ++++++++++++------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f1c5a60abbe..4f9b8e722a95 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,7 @@ + diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index acec5e3cd11d..f7bb77362443 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -58,34 +58,42 @@ class LocalNetworkPermissionRequester( return@launch } - pendingResult = { isGranted -> - if (isGranted) { - onPermissionGranted() - onGranted() - } else { - showDeniedSnackbar() - onDenied() - } - } - if (activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)) { - showRationale() + showRationale(onGranted, onDenied) } else { - permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + launchRequest(onGranted, onDenied) } } } @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) - private fun showRationale() { + private fun launchRequest(onGranted: () -> Unit, onDenied: () -> Unit) { + pendingResult = { isGranted -> handleResult(isGranted, onGranted, onDenied) } + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun handleResult(isGranted: Boolean, onGranted: () -> Unit, onDenied: () -> Unit) { + if (isGranted) { + onPermissionGranted() + onGranted() + return + } + + showDeniedSnackbar(onGranted, onDenied) + onDenied() + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun showRationale(onGranted: () -> Unit, onDenied: () -> Unit) { val builder = MaterialAlertDialogBuilder(activity) .setTitle(R.string.local_network_permission_title) .setMessage(activity.getString(R.string.local_network_permission_rationale, appName)) - .setPositiveButton(R.string.common_ok) { _, _ -> - permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + .setPositiveButton(R.string.common_ok) { _, _ -> launchRequest(onGranted, onDenied) } + .setNegativeButton(R.string.common_cancel) { _, _ -> + handleResult(isGranted = false, onGranted, onDenied) } - .setNegativeButton(R.string.common_cancel) { _, _ -> deliverResult(isGranted = false) } - .setOnCancelListener { deliverResult(isGranted = false) } + .setOnCancelListener { handleResult(isGranted = false, onGranted, onDenied) } viewThemeUtils().dialog.colorMaterialAlertDialogBackground(activity, builder) builder.show() @@ -97,11 +105,21 @@ class LocalNetworkPermissionRequester( callback(isGranted) } - private fun showDeniedSnackbar() { + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun showDeniedSnackbar(onGranted: () -> Unit, onDenied: () -> Unit) { val message = activity.getString(R.string.local_network_permission_denied, appName) - SnackbarUtil.create(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG) - ?.setAction(R.string.actionbar_settings) { openAppSettings() } - ?.show() + val snackbar = SnackbarUtil.create(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG) + ?: return + + val canShowSystemDialog = + activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK) + if (canShowSystemDialog) { + snackbar.setAction(R.string.permission_allow) { launchRequest(onGranted, onDenied) } + } else { + snackbar.setAction(R.string.actionbar_settings) { openAppSettings() } + } + + snackbar.show() } private fun openAppSettings() { From 02ea1a59977e661487269963a9060ce758884a89 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 14:40:47 +0300 Subject: [PATCH 05/13] use native dialog Signed-off-by: alperozturk96 --- .../local/LocalNetworkPermissionRequester.kt | 24 +------------------ .../authentication/AuthenticatorActivity.kt | 1 - .../ui/activity/FileDisplayActivity.kt | 1 - app/src/main/res/values/strings.xml | 1 - 4 files changed, 1 insertion(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index f7bb77362443..c4690aef0304 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -15,17 +15,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.nextcloud.utils.SnackbarUtil import com.owncloud.android.R import com.owncloud.android.utils.PermissionUtil -import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.launch class LocalNetworkPermissionRequester( private val activity: AppCompatActivity, - private val viewThemeUtils: () -> ViewThemeUtils, private val onPermissionGranted: () -> Unit ) { private var pendingResult: ((Boolean) -> Unit)? = null @@ -58,11 +55,7 @@ class LocalNetworkPermissionRequester( return@launch } - if (activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK)) { - showRationale(onGranted, onDenied) - } else { - launchRequest(onGranted, onDenied) - } + launchRequest(onGranted, onDenied) } } @@ -84,21 +77,6 @@ class LocalNetworkPermissionRequester( onDenied() } - @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) - private fun showRationale(onGranted: () -> Unit, onDenied: () -> Unit) { - val builder = MaterialAlertDialogBuilder(activity) - .setTitle(R.string.local_network_permission_title) - .setMessage(activity.getString(R.string.local_network_permission_rationale, appName)) - .setPositiveButton(R.string.common_ok) { _, _ -> launchRequest(onGranted, onDenied) } - .setNegativeButton(R.string.common_cancel) { _, _ -> - handleResult(isGranted = false, onGranted, onDenied) - } - .setOnCancelListener { handleResult(isGranted = false, onGranted, onDenied) } - - viewThemeUtils().dialog.colorMaterialAlertDialogBackground(activity, builder) - builder.show() - } - private fun deliverResult(isGranted: Boolean) { val callback = pendingResult ?: return pendingResult = null diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index 5f1cd0be2afc..8e6e4a00e9e0 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -164,7 +164,6 @@ open class AuthenticatorActivity : private val loginFlow by lazy { LoginFlowV2(clientRepository, lifecycleScope, ::completeLoginFlow) } private val localNetworkPermission = LocalNetworkPermissionRequester( activity = this, - viewThemeUtils = { viewThemeUtilsFactory.withDefaultSchemes() }, onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } ) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 8008e698bfa3..5357d6f55751 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -222,7 +222,6 @@ class FileDisplayActivity : private val dialogs by lazy { FileDisplayDialogs(this, viewThemeUtils) } private val localNetworkPermission = LocalNetworkPermissionRequester( activity = this, - viewThemeUtils = { viewThemeUtils }, onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } ) private val uploadHandler by lazy { FileDisplayUploadHandler(this, connectivityService, fileDataStorageManager) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36dcd84ff95d..7e6fd37e35c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1117,7 +1117,6 @@ Deny Allow Access to local network - %1$s needs the Nearby devices permission to connect to your server on the local network. %1$s can\'t connect to your server on the local network without the Nearby devices permission. Uploads, downloads and backups can\'t reach your server on the local network. Tap to allow %1$s to access nearby devices. Note to recipient From e27d73ffd7bab588756a54b95e610cc924e7cf58 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 2 Nov 2026 14:44:42 +0300 Subject: [PATCH 06/13] simplify message Signed-off-by: alperozturk96 --- .../local/LocalNetworkPermissionRequester.kt | 13 +++++++------ app/src/main/res/values/strings.xml | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index c4690aef0304..cd6a0472a3e7 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -30,9 +30,6 @@ class LocalNetworkPermissionRequester( private val permissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission(), ::deliverResult) - private val appName: String - get() = activity.getString(R.string.app_name) - fun requestIfNeeded(serverUrls: List, onGranted: () -> Unit = {}, onDenied: () -> Unit = {}) { if (!PermissionUtil.isLocalNetworkPermissionRequired(activity)) { onGranted() @@ -85,12 +82,16 @@ class LocalNetworkPermissionRequester( @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) private fun showDeniedSnackbar(onGranted: () -> Unit, onDenied: () -> Unit) { - val message = activity.getString(R.string.local_network_permission_denied, appName) + 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 = SnackbarUtil.create(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG) ?: return - val canShowSystemDialog = - activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_LOCAL_NETWORK) if (canShowSystemDialog) { snackbar.setAction(R.string.permission_allow) { launchRequest(onGranted, onDenied) } } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e6fd37e35c4..4298e7f8c00b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1117,7 +1117,8 @@ Deny Allow Access to local network - %1$s can\'t connect to your server on the local network without the Nearby devices permission. + Allow nearby devices to reach your local server + Go to Permissions → Nearby devices → Allow Uploads, downloads and backups can\'t reach your server on the local network. Tap to allow %1$s to access nearby devices. Note to recipient Could not send note From c4e8a32eb873b36098c6ae7cea87304e1a4771f6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 08:41:03 +0300 Subject: [PATCH 07/13] simplify isLocalServer Signed-off-by: alperozturk96 --- .../nextcloud/client/network/local/LocalNetworkAddress.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 39b7282873a8..6ed711aa50a9 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -21,12 +21,8 @@ object LocalNetworkAddress { suspend fun isLocalServer(serverUrl: String): Boolean { val host = runCatching { URI(serverUrl).host }.getOrNull() ?: return false - - if (host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR).endsWith(MDNS_SUFFIX)) { - return true - } - - return withContext(Dispatchers.IO) { + val caseInsensitiveHost = host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR) + return caseInsensitiveHost.endsWith(MDNS_SUFFIX) || withContext(Dispatchers.IO) { resolve(host).any { it.isLocalNetworkAddress() } } } From 5253f3fc179e72f2c92d9e838806babce0f27ade Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 09:10:43 +0300 Subject: [PATCH 08/13] add clarifications Signed-off-by: alperozturk96 --- .../client/network/local/LocalNetworkAddress.kt | 9 +++++++++ .../network/local/LocalNetworkPermissionHandler.kt | 2 ++ 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 6ed711aa50a9..6f4c3e58dbf4 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -16,7 +16,11 @@ import java.util.Locale object LocalNetworkAddress { private const val MDNS_SUFFIX = ".local" private const val HOST_LABEL_SEPARATOR = '.' + + // 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 suspend fun isLocalServer(serverUrl: String): Boolean { @@ -27,13 +31,18 @@ object LocalNetworkAddress { } } + // Looks up all IP addresses of the host name private fun resolve(host: String): List = 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 } } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt index b91db248b68e..68533ced53be 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt @@ -58,6 +58,8 @@ class LocalNetworkPermissionHandler @Inject constructor( fun onPermissionGranted() { Log_OC.d(TAG, "local network permission granted, restarting failed uploads") notificationManager.cancel(NOTIFICATION_ID) + + // only this can be retried right away FilesSyncHelper.restartUploadsIfNeeded( uploadsStorageManager, accountManager, From 4e54d2a87920672388d5318ea75086c8324e1729 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 09:12:05 +0300 Subject: [PATCH 09/13] add clarifications Signed-off-by: alperozturk96 --- .../com/nextcloud/client/network/local/LocalNetworkAddress.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 6f4c3e58dbf4..273bde83406f 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -38,10 +38,13 @@ object LocalNetworkAddress { 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 } From 67a3eba8a84a0508bf0eee9d8018f50c75439cf7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 09:38:40 +0300 Subject: [PATCH 10/13] add connected routes Signed-off-by: alperozturk96 --- .../network/local/LocalNetworkAddress.kt | 7 ++- .../local/LocalNetworkPermissionHandler.kt | 4 +- .../local/LocalNetworkPermissionRequester.kt | 3 +- .../network/local/LocalNetworkRoutes.kt | 43 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 273bde83406f..2495c118629d 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -23,11 +23,14 @@ object LocalNetworkAddress { // Private IPv6 addresses start with fc or fd private const val UNIQUE_LOCAL_PREFIX = 0xFC - suspend fun isLocalServer(serverUrl: String): Boolean { + suspend fun isLocalServer(serverUrl: String, routes: LocalNetworkRoutes? = null): Boolean { val host = runCatching { URI(serverUrl).host }.getOrNull() ?: return false val caseInsensitiveHost = host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR) return caseInsensitiveHost.endsWith(MDNS_SUFFIX) || withContext(Dispatchers.IO) { - resolve(host).any { it.isLocalNetworkAddress() } + val directlyConnectedRoutes = routes?.directlyConnectedRoutes().orEmpty() + resolve(host).any { address -> + address.isLocalNetworkAddress() || directlyConnectedRoutes.any { it.matches(address) } + } } } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt index 68533ced53be..ace3848cf534 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt @@ -44,6 +44,8 @@ class LocalNetworkPermissionHandler @Inject constructor( const val ACTION_REQUEST_PERMISSION = "com.nextcloud.client.network.REQUEST_LOCAL_NETWORK_PERMISSION" } + private val routes = LocalNetworkRoutes(context) + val serverUrls: List get() = accountManager.allUsers.map { it.server.uri.toString() } @@ -76,7 +78,7 @@ class LocalNetworkPermissionHandler @Inject constructor( } scope.launch { - if (serverUrls.none { LocalNetworkAddress.isLocalServer(it) }) { + if (serverUrls.none { LocalNetworkAddress.isLocalServer(it, routes) }) { return@launch } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index cd6a0472a3e7..4445155df901 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -26,6 +26,7 @@ class LocalNetworkPermissionRequester( private val onPermissionGranted: () -> Unit ) { private var pendingResult: ((Boolean) -> Unit)? = null + private val routes by lazy { LocalNetworkRoutes(activity) } private val permissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission(), ::deliverResult) @@ -47,7 +48,7 @@ class LocalNetworkPermissionRequester( } activity.lifecycleScope.launch { - if (serverUrls.none { LocalNetworkAddress.isLocalServer(it) }) { + if (serverUrls.none { LocalNetworkAddress.isLocalServer(it, routes) }) { onGranted() return@launch } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt new file mode 100644 index 000000000000..48ee619e43e9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.network.local + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.RouteInfo +import android.os.Build +import androidx.annotation.RequiresApi + +class LocalNetworkRoutes(context: Context) { + private val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + + // Android only treats Wi-Fi and Ethernet as local network, VPN and cellular are excluded + @Suppress("DEPRECATION") + fun directlyConnectedRoutes(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) { + return emptyList() + } + + val manager = connectivityManager ?: return emptyList() + return manager.allNetworks + .filter { manager.isLocalNetwork(it) } + .flatMap { manager.getLinkProperties(it)?.routes.orEmpty() } + .filter { it.isDirectlyConnected() } + } + + private fun ConnectivityManager.isLocalNetwork(network: Network): Boolean { + val capabilities = getNetworkCapabilities(network) ?: return false + return !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) && + !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } + + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) + private fun RouteInfo.isDirectlyConnected(): Boolean = + type == RouteInfo.RTN_UNICAST && !hasGateway() && !isDefaultRoute +} From 7712b22761437c8ac8b702ef8f55fbf261ffa0b5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 10:01:45 +0300 Subject: [PATCH 11/13] Finds servers on the same Wi-Fi as the phone Signed-off-by: alperozturk96 --- .../local/LocalNetworkPermissionHandler.kt | 8 ++- .../local/LocalNetworkPermissionRequester.kt | 39 +++++++++++-- .../network/local/LocalNetworkRoutes.kt | 57 ++++++++++++++----- .../authentication/AuthenticatorActivity.kt | 1 + .../ui/activity/FileDisplayActivity.kt | 1 + 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt index ace3848cf534..ddf9d36030fc 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt @@ -36,6 +36,7 @@ class LocalNetworkPermissionHandler @Inject constructor( private val connectivityService: ConnectivityService, private val powerManagementService: PowerManagementService, private val notificationManager: NotificationManager, + private val routes: LocalNetworkRoutes, @param:ApplicationScope private val scope: CoroutineScope ) { companion object { @@ -44,11 +45,12 @@ class LocalNetworkPermissionHandler @Inject constructor( const val ACTION_REQUEST_PERMISSION = "com.nextcloud.client.network.REQUEST_LOCAL_NETWORK_PERMISSION" } - private val routes = LocalNetworkRoutes(context) - val serverUrls: List get() = accountManager.allUsers.map { it.server.uri.toString() } + suspend fun hasLocalServer(serverUrls: List): Boolean = + serverUrls.any { LocalNetworkAddress.isLocalServer(it, routes) } + fun notifyIfPermissionMissing() { if (!PermissionUtil.isLocalNetworkPermissionRequired(context)) { return @@ -78,7 +80,7 @@ class LocalNetworkPermissionHandler @Inject constructor( } scope.launch { - if (serverUrls.none { LocalNetworkAddress.isLocalServer(it, routes) }) { + if (!hasLocalServer(serverUrls)) { return@launch } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index 4445155df901..4abd942c017b 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -14,6 +14,8 @@ import android.provider.Settings import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import com.nextcloud.utils.SnackbarUtil @@ -23,14 +25,25 @@ import kotlinx.coroutines.launch class LocalNetworkPermissionRequester( private val activity: AppCompatActivity, + private val hasLocalServer: suspend (List) -> Boolean, private val onPermissionGranted: () -> Unit ) { private var pendingResult: ((Boolean) -> Unit)? = null - private val routes by lazy { LocalNetworkRoutes(activity) } + private var pendingSettingsGrant: (() -> Unit)? = null private val permissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission(), ::deliverResult) + init { + activity.lifecycle.addObserver( + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + deliverSettingsResult() + } + } + ) + } + fun requestIfNeeded(serverUrls: List, onGranted: () -> Unit = {}, onDenied: () -> Unit = {}) { if (!PermissionUtil.isLocalNetworkPermissionRequired(activity)) { onGranted() @@ -48,7 +61,7 @@ class LocalNetworkPermissionRequester( } activity.lifecycleScope.launch { - if (serverUrls.none { LocalNetworkAddress.isLocalServer(it, routes) }) { + if (!hasLocalServer(serverUrls)) { onGranted() return@launch } @@ -81,6 +94,20 @@ class LocalNetworkPermissionRequester( callback(isGranted) } + private fun deliverSettingsResult() { + val onGranted = pendingSettingsGrant ?: return + pendingSettingsGrant = null + + if (!PermissionUtil.isLocalNetworkPermissionRequired(activity) || + !PermissionUtil.hasLocalNetworkPermission(activity) + ) { + return + } + + onPermissionGranted() + onGranted() + } + @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) private fun showDeniedSnackbar(onGranted: () -> Unit, onDenied: () -> Unit) { val canShowSystemDialog = @@ -90,19 +117,21 @@ class LocalNetworkPermissionRequester( } else { R.string.local_network_permission_settings_path } - val snackbar = SnackbarUtil.create(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG) + val duration = if (canShowSystemDialog) Snackbar.LENGTH_LONG else Snackbar.LENGTH_INDEFINITE + val snackbar = SnackbarUtil.create(activity.findViewById(android.R.id.content), message, duration) ?: return if (canShowSystemDialog) { snackbar.setAction(R.string.permission_allow) { launchRequest(onGranted, onDenied) } } else { - snackbar.setAction(R.string.actionbar_settings) { openAppSettings() } + snackbar.setAction(R.string.actionbar_settings) { openAppSettings(onGranted) } } snackbar.show() } - private fun openAppSettings() { + private fun openAppSettings(onGranted: () -> Unit) { + pendingSettingsGrant = onGranted val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts(PACKAGE_SCHEME, activity.packageName, null) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt index 48ee619e43e9..2956e2e1ee73 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt @@ -8,35 +8,66 @@ package com.nextcloud.client.network.local 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 com.owncloud.android.utils.PermissionUtil +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton -class LocalNetworkRoutes(context: Context) { - private val connectivityManager = context.getSystemService(ConnectivityManager::class.java) +// Finds servers on the same Wi-Fi as the phone, even when their IP is not a private one like 192.168.x.x +@Singleton +class LocalNetworkRoutes @Inject constructor(context: Context) { + // Network settings (IPs, routes) of every Wi-Fi, Ethernet etc. network the phone is connected to + private val linkProperties = ConcurrentHashMap() - // Android only treats Wi-Fi and Ethernet as local network, VPN and cellular are excluded - @Suppress("DEPRECATION") + // 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 { + if (PermissionUtil.isLocalNetworkPermissionRequired(context)) { + context.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 { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) { return emptyList() } - val manager = connectivityManager ?: return emptyList() - return manager.allNetworks - .filter { manager.isLocalNetwork(it) } - .flatMap { manager.getLinkProperties(it)?.routes.orEmpty() } + return linkProperties.values + .flatMap { it.routes } .filter { it.isDirectlyConnected() } } - private fun ConnectivityManager.isLocalNetwork(network: Network): Boolean { - val capabilities = getNetworkCapabilities(network) ?: return false - return !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) && - !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - } + // 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 diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index 8e6e4a00e9e0..eb8458fa3f79 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -164,6 +164,7 @@ open class AuthenticatorActivity : private val loginFlow by lazy { LoginFlowV2(clientRepository, lifecycleScope, ::completeLoginFlow) } private val localNetworkPermission = LocalNetworkPermissionRequester( activity = this, + hasLocalServer = { localNetworkPermissionHandler.hasLocalServer(it) }, onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } ) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 5357d6f55751..c8f9acb36ee2 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -222,6 +222,7 @@ class FileDisplayActivity : private val dialogs by lazy { FileDisplayDialogs(this, viewThemeUtils) } private val localNetworkPermission = LocalNetworkPermissionRequester( activity = this, + hasLocalServer = { localNetworkPermissionHandler.hasLocalServer(it) }, onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() } ) private val uploadHandler by lazy { FileDisplayUploadHandler(this, connectivityService, fileDataStorageManager) } From 7e70aa602f504c5bc12f5ff96b7941f199508b60 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 10:26:25 +0300 Subject: [PATCH 12/13] handle user type Signed-off-by: alperozturk96 --- .../network/local/LocalNetworkAddress.kt | 10 +++++++- .../network/local/LocalNetworkAddressTest.kt | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt index 2495c118629d..968aee179019 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt @@ -16,6 +16,8 @@ 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 @@ -24,7 +26,7 @@ object LocalNetworkAddress { private const val UNIQUE_LOCAL_PREFIX = 0xFC suspend fun isLocalServer(serverUrl: String, routes: LocalNetworkRoutes? = null): Boolean { - val host = runCatching { URI(serverUrl).host }.getOrNull() ?: return false + val host = parseHost(serverUrl) ?: return false val caseInsensitiveHost = host.lowercase(Locale.ROOT).trimEnd(HOST_LABEL_SEPARATOR) return caseInsensitiveHost.endsWith(MDNS_SUFFIX) || withContext(Dispatchers.IO) { val directlyConnectedRoutes = routes?.directlyConnectedRoutes().orEmpty() @@ -34,6 +36,12 @@ object LocalNetworkAddress { } } + // 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 = runCatching { InetAddress.getAllByName(host).toList() }.getOrDefault(emptyList()) diff --git a/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt b/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt index 631820b803db..28200f3f908a 100644 --- a/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt +++ b/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt @@ -60,6 +60,31 @@ class LocalNetworkAddressTest { 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")) From 69ddacb570af2da161d67d7bc20e4e419be3b3f1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 4 Nov 2026 11:29:16 +0300 Subject: [PATCH 13/13] use common lib Signed-off-by: alperozturk96 --- .../nextcloud/client/network/NetworkModule.kt | 5 + .../network/local/LocalNetworkAddress.kt | 62 --------- .../local/LocalNetworkPermissionHandler.kt | 13 +- .../local/LocalNetworkPermissionRequester.kt | 10 +- .../network/local/LocalNetworkRoutes.kt | 74 ---------- .../owncloud/android/utils/PermissionUtil.kt | 10 -- .../network/local/LocalNetworkAddressTest.kt | 127 ------------------ gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 32 +++++ 9 files changed, 52 insertions(+), 283 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt delete mode 100644 app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt delete mode 100644 app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt diff --git a/app/src/main/java/com/nextcloud/client/network/NetworkModule.kt b/app/src/main/java/com/nextcloud/client/network/NetworkModule.kt index 22ea9244f8a3..ef8dcb6c1f71 100644 --- a/app/src/main/java/com/nextcloud/client/network/NetworkModule.kt +++ b/app/src/main/java/com/nextcloud/client/network/NetworkModule.kt @@ -7,6 +7,7 @@ package com.nextcloud.client.network import android.content.Context +import com.nextcloud.android.common.core.network.LocalNetworkRoutes import com.nextcloud.client.account.UserAccountManager import com.nextcloud.operations.GetMethod import dagger.Module @@ -34,4 +35,8 @@ class NetworkModule { @Provides @Singleton fun clientFactory(context: Context): ClientFactory = ClientFactoryImpl(context) + + @Provides + @Singleton + fun localNetworkRoutes(context: Context): LocalNetworkRoutes = LocalNetworkRoutes(context) } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt deleted file mode 100644 index 968aee179019..000000000000 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkAddress.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.client.network.local - -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 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 - - suspend 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) || withContext(Dispatchers.IO) { - val directlyConnectedRoutes = routes?.directlyConnectedRoutes().orEmpty() - 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 = - 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 - } -} diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt index ddf9d36030fc..7d8ed94f5567 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt @@ -14,6 +14,9 @@ import android.content.Intent import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import com.nextcloud.android.common.core.network.LocalNetworkAddress +import com.nextcloud.android.common.core.network.LocalNetworkPermission +import com.nextcloud.android.common.core.network.LocalNetworkRoutes import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.di.ApplicationScope @@ -24,9 +27,10 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.FilesSyncHelper -import com.owncloud.android.utils.PermissionUtil import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject class LocalNetworkPermissionHandler @Inject constructor( @@ -48,11 +52,12 @@ class LocalNetworkPermissionHandler @Inject constructor( val serverUrls: List get() = accountManager.allUsers.map { it.server.uri.toString() } - suspend fun hasLocalServer(serverUrls: List): Boolean = + suspend fun hasLocalServer(serverUrls: List): Boolean = withContext(Dispatchers.IO) { serverUrls.any { LocalNetworkAddress.isLocalServer(it, routes) } + } fun notifyIfPermissionMissing() { - if (!PermissionUtil.isLocalNetworkPermissionRequired(context)) { + if (!LocalNetworkPermission.isRequired(context)) { return } @@ -74,7 +79,7 @@ class LocalNetworkPermissionHandler @Inject constructor( @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) private fun notifyIfLocalServerUnreachable() { - if (PermissionUtil.hasLocalNetworkPermission(context)) { + if (LocalNetworkPermission.isGranted(context)) { notificationManager.cancel(NOTIFICATION_ID) return } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt index 4abd942c017b..e26e5d2b5a50 100644 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt +++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt @@ -18,9 +18,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.core.network.LocalNetworkPermission import com.nextcloud.utils.SnackbarUtil import com.owncloud.android.R -import com.owncloud.android.utils.PermissionUtil import kotlinx.coroutines.launch class LocalNetworkPermissionRequester( @@ -45,7 +45,7 @@ class LocalNetworkPermissionRequester( } fun requestIfNeeded(serverUrls: List, onGranted: () -> Unit = {}, onDenied: () -> Unit = {}) { - if (!PermissionUtil.isLocalNetworkPermissionRequired(activity)) { + if (!LocalNetworkPermission.isRequired(activity)) { onGranted() return } @@ -55,7 +55,7 @@ class LocalNetworkPermissionRequester( @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN) private fun requestForLocalServers(serverUrls: List, onGranted: () -> Unit, onDenied: () -> Unit) { - if (PermissionUtil.hasLocalNetworkPermission(activity)) { + if (LocalNetworkPermission.isGranted(activity)) { onGranted() return } @@ -98,8 +98,8 @@ class LocalNetworkPermissionRequester( val onGranted = pendingSettingsGrant ?: return pendingSettingsGrant = null - if (!PermissionUtil.isLocalNetworkPermissionRequired(activity) || - !PermissionUtil.hasLocalNetworkPermission(activity) + if (!LocalNetworkPermission.isRequired(activity) || + !LocalNetworkPermission.isGranted(activity) ) { return } diff --git a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt deleted file mode 100644 index 2956e2e1ee73..000000000000 --- a/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkRoutes.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.client.network.local - -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 com.owncloud.android.utils.PermissionUtil -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -// Finds servers on the same Wi-Fi as the phone, even when their IP is not a private one like 192.168.x.x -@Singleton -class LocalNetworkRoutes @Inject constructor(context: Context) { - // Network settings (IPs, routes) of every Wi-Fi, Ethernet etc. network the phone is connected to - private val linkProperties = ConcurrentHashMap() - - // 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 { - if (PermissionUtil.isLocalNetworkPermissionRequired(context)) { - context.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 { - 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 -} diff --git a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt index ee8895e1bb07..dd062ee63b67 100644 --- a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt @@ -18,7 +18,6 @@ import android.content.pm.ResolveInfo import android.os.Build import android.os.Environment import android.provider.Settings -import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat @@ -48,15 +47,6 @@ object PermissionUtil { fun checkPermissions(context: Context, permissions: Array): Boolean = permissions.all { checkSelfPermission(context, it) } - - @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.CINNAMON_BUN) - fun isLocalNetworkPermissionRequired(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 hasLocalNetworkPermission(context: Context): Boolean = - checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) // endregion /** diff --git a/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt b/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt deleted file mode 100644 index 28200f3f908a..000000000000 --- a/app/src/test/java/com/nextcloud/client/network/local/LocalNetworkAddressTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.client.network.local - -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 `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("")) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7122cb80ca5..d5c8939a66fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] -androidCommonLibraryVersion = "0.34.0" +androidCommonLibraryVersion = "40879d62b0" androidGifDrawableVersion = "1.2.32" androidImageCropperVersion = "4.7.0" androidLibraryVersion ="0c7fb895b2b621d998e8a32faec2db9ca046c1ef" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index eea1686f8dde..5f077eee8a01 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -24226,6 +24226,14 @@ + + + + + + + + @@ -24526,6 +24534,14 @@ + + + + + + + + @@ -24822,6 +24838,14 @@ + + + + + + + + @@ -29798,6 +29822,14 @@ + + + + + + + +