Skip to content
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,4 +35,8 @@ class NetworkModule {
@Provides
@Singleton
fun clientFactory(context: Context): ClientFactory = ClientFactoryImpl(context)

@Provides
@Singleton
fun localNetworkRoutes(context: Context): LocalNetworkRoutes = LocalNetworkRoutes(context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<String>
get() = accountManager.allUsers.map { it.server.uri.toString() }

suspend fun hasLocalServer(serverUrls: List<String>): 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
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* 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<String>) -> 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<String>, 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<String>, 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"
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/owncloud/android/MainApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -351,6 +354,7 @@ public void onCreate() {
walledCheckCache);
initContactsBackup(accountManager, backgroundJobManager);
notificationChannels();
localNetworkPermissionHandler.notifyIfPermissionMissing();

if (backgroundJobManager != null) {
backgroundJobManager.scheduleMediaFoldersDetectionJob();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading