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/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/LocalNetworkPermissionHandler.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt
new file mode 100644
index 000000000000..7d8ed94f5567
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionHandler.kt
@@ -0,0 +1,125 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.nextcloud.client.network.local
+
+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.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
+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
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.notifications.NotificationUtils
+import com.owncloud.android.utils.FilesSyncHelper
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+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,
+ private val routes: LocalNetworkRoutes,
+ @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() }
+
+ suspend fun hasLocalServer(serverUrls: List): Boolean = withContext(Dispatchers.IO) {
+ serverUrls.any { LocalNetworkAddress.isLocalServer(it, routes) }
+ }
+
+ fun notifyIfPermissionMissing() {
+ if (!LocalNetworkPermission.isRequired(context)) {
+ return
+ }
+
+ notifyIfLocalServerUnreachable()
+ }
+
+ 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,
+ connectivityService,
+ powerManagementService
+ )
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun notifyIfLocalServerUnreachable() {
+ if (LocalNetworkPermission.isGranted(context)) {
+ notificationManager.cancel(NOTIFICATION_ID)
+ return
+ }
+
+ scope.launch {
+ if (!hasLocalServer(serverUrls)) {
+ 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/local/LocalNetworkPermissionRequester.kt b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt
new file mode 100644
index 000000000000..e26e5d2b5a50
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/network/local/LocalNetworkPermissionRequester.kt
@@ -0,0 +1,145 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.nextcloud.client.network.local
+
+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.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 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 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 (!LocalNetworkPermission.isRequired(activity)) {
+ onGranted()
+ return
+ }
+
+ requestForLocalServers(serverUrls, onGranted, onDenied)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun requestForLocalServers(serverUrls: List, onGranted: () -> Unit, onDenied: () -> Unit) {
+ if (LocalNetworkPermission.isGranted(activity)) {
+ onGranted()
+ return
+ }
+
+ activity.lifecycleScope.launch {
+ if (!hasLocalServer(serverUrls)) {
+ onGranted()
+ return@launch
+ }
+
+ launchRequest(onGranted, onDenied)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ 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()
+ }
+
+ private fun deliverResult(isGranted: Boolean) {
+ val callback = pendingResult ?: return
+ pendingResult = null
+ callback(isGranted)
+ }
+
+ private fun deliverSettingsResult() {
+ val onGranted = pendingSettingsGrant ?: return
+ pendingSettingsGrant = null
+
+ if (!LocalNetworkPermission.isRequired(activity) ||
+ !LocalNetworkPermission.isGranted(activity)
+ ) {
+ return
+ }
+
+ onPermissionGranted()
+ onGranted()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
+ private fun showDeniedSnackbar(onGranted: () -> Unit, onDenied: () -> Unit) {
+ 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 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(onGranted) }
+ }
+
+ snackbar.show()
+ }
+
+ private fun openAppSettings(onGranted: () -> Unit) {
+ pendingSettingsGrant = onGranted
+ 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/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java
index 0454dc11a200..17aae9712ae6 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.local.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 2ef83e482af3..eb8458fa3f79 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,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.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
@@ -145,6 +147,9 @@ open class AuthenticatorActivity :
@Inject
lateinit var clientRepository: ClientRepository
+ @Inject
+ lateinit var localNetworkPermissionHandler: LocalNetworkPermissionHandler
+
@get:VisibleForTesting
var accountSetupBinding: AccountSetupBinding? = null
private set
@@ -157,6 +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(
+ activity = this,
+ hasLocalServer = { localNetworkPermissionHandler.hasLocalServer(it) },
+ onPermissionGranted = { localNetworkPermissionHandler.onPermissionGranted() }
+ )
private var authenticatorAction = ACTION_CREATE
private var account: Account? = null
@@ -519,6 +529,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 +673,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..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
@@ -63,6 +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.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
@@ -208,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
@@ -215,6 +220,11 @@ class FileDisplayActivity :
private lateinit var folderRefreshScheduler: FolderRefreshScheduler
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) }
private val syncReceiver = LocalIntentReceiver(::onSyncBroadcastReceived)
@@ -302,6 +312,7 @@ class FileDisplayActivity :
if (savedInstanceState == null) {
addInitialListOfFilesFragment()
+ requestLocalNetworkPermissionIfNeeded()
if (intent.getBooleanExtra(EXTRA_RETURN_TO_PREVIEW, false)) {
handleNavigationIntent(intent)
@@ -685,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 02ad7b2f9b0c..4298e7f8c00b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1116,6 +1116,10 @@
Allow %1$s to access your Nextcloud account %2$s?
Deny
Allow
+ Access to local network
+ 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
Note
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 @@
+
+
+
+
+
+
+
+