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 @@ + + + + + + + +