diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 8154074c608d..ec026265437b 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.kt @@ -43,7 +43,9 @@ import com.nextcloud.client.notifications.AppNotificationManager import com.nextcloud.client.notifications.AppNotificationManagerImpl import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.repository.ClientRepository +import com.nextcloud.repository.ExternalStorageRepository import com.nextcloud.repository.RemoteClientRepository +import com.nextcloud.repository.RemoteExternalStorageRepository import com.nextcloud.utils.e2ee.E2EEActionResolver import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator @@ -119,6 +121,9 @@ internal class AppModule { @Provides fun clientRepository(repository: RemoteClientRepository): ClientRepository = repository + @Provides + fun externalStorageRepository(repository: RemoteExternalStorageRepository): ExternalStorageRepository = repository + @Provides fun fileUploadHelper(): FileUploadHelper = FileUploadHelper.instance() diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt index 745696d45571..9277f101f501 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt @@ -84,6 +84,7 @@ import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment import com.owncloud.android.ui.dialog.CreateFolderDialogFragment import com.owncloud.android.ui.dialog.ExpirationDatePickerDialogFragment +import com.owncloud.android.ui.dialog.ExternalStorageAuthDialogFragment import com.owncloud.android.ui.dialog.IndeterminateProgressDialog import com.owncloud.android.ui.dialog.LoadingDialog import com.owncloud.android.ui.dialog.LocalStoragePathPickerDialogFragment @@ -529,4 +530,7 @@ internal interface ComponentsModule { @ContributesAndroidInjector fun albumSharingBottomSheet(): AlbumSharingBottomSheet + + @ContributesAndroidInjector + fun externalStorageAuthDialogFragment(): ExternalStorageAuthDialogFragment } diff --git a/app/src/main/java/com/nextcloud/repository/ExternalStorageMountsResult.kt b/app/src/main/java/com/nextcloud/repository/ExternalStorageMountsResult.kt new file mode 100644 index 000000000000..4f0dc928a965 --- /dev/null +++ b/app/src/main/java/com/nextcloud/repository/ExternalStorageMountsResult.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.repository + +import com.nextcloud.android.lib.resources.externalstorage.model.ExternalStorageMount + +sealed interface ExternalStorageMountsResult { + data class Success(val mounts: List) : ExternalStorageMountsResult + + data object Failure : ExternalStorageMountsResult +} diff --git a/app/src/main/java/com/nextcloud/repository/ExternalStorageRepository.kt b/app/src/main/java/com/nextcloud/repository/ExternalStorageRepository.kt new file mode 100644 index 000000000000..9b83e604c240 --- /dev/null +++ b/app/src/main/java/com/nextcloud/repository/ExternalStorageRepository.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.repository + +interface ExternalStorageRepository { + suspend fun fetchMounts(): ExternalStorageMountsResult +} diff --git a/app/src/main/java/com/nextcloud/repository/RemoteExternalStorageRepository.kt b/app/src/main/java/com/nextcloud/repository/RemoteExternalStorageRepository.kt new file mode 100644 index 000000000000..42ff4bef5134 --- /dev/null +++ b/app/src/main/java/com/nextcloud/repository/RemoteExternalStorageRepository.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.repository + +import com.nextcloud.android.lib.resources.externalstorage.ExternalStorageListMountsRemoteOperation +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class RemoteExternalStorageRepository @Inject constructor(private val clientRepository: ClientRepository) : + ExternalStorageRepository { + + override suspend fun fetchMounts(): ExternalStorageMountsResult { + val client = clientRepository.getNextcloudClient() ?: return ExternalStorageMountsResult.Failure + val result = withContext(Dispatchers.IO) { ExternalStorageListMountsRemoteOperation().execute(client) } + if (!result.isSuccess) { + Log_OC.e(TAG, "Could not fetch external storage mounts") + return ExternalStorageMountsResult.Failure + } + return ExternalStorageMountsResult.Success(result.resultData.orEmpty()) + } + + companion object { + private const val TAG = "ExternalStorageRepository" + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ExternalStorageMountExtension.kt b/app/src/main/java/com/nextcloud/utils/extensions/ExternalStorageMountExtension.kt new file mode 100644 index 000000000000..6505d65e740f --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ExternalStorageMountExtension.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.android.lib.resources.externalstorage.model.ExternalStorageMount +import com.owncloud.android.datamodel.OCFile + +fun ExternalStorageMount.remotePath(): String { + val segments = listOf(path.trim(OCFile.PATH_SEPARATOR[0]), name).filter { it.isNotEmpty() } + return segments.joinToString(OCFile.PATH_SEPARATOR, OCFile.PATH_SEPARATOR, OCFile.PATH_SEPARATOR) +} 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 d3ae4fa89eee..eb38a49d386e 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 @@ -52,6 +52,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.core.utils.ecosystem.AccountReceiverCallback +import com.nextcloud.android.lib.resources.externalstorage.model.AuthMechanism import com.nextcloud.appReview.InAppReviewHelper import com.nextcloud.client.account.User import com.nextcloud.client.core.AsyncRunner @@ -67,6 +68,8 @@ import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.audio.AudioPlayerLauncher import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.IntentUtil +import com.nextcloud.repository.ExternalStorageMountsResult +import com.nextcloud.repository.ExternalStorageRepository import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.getParcelableArgument @@ -74,6 +77,7 @@ import com.nextcloud.utils.extensions.getSerializableArgument import com.nextcloud.utils.extensions.isActive import com.nextcloud.utils.extensions.lastFragment import com.nextcloud.utils.extensions.navigateToAllFiles +import com.nextcloud.utils.extensions.remotePath import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.view.FastScrollUtils import com.owncloud.android.MainApp @@ -115,6 +119,7 @@ import com.owncloud.android.ui.activity.filedisplayactivity.LocalIntentReceiver import com.owncloud.android.ui.asynctasks.FetchRemoteFileTask import com.owncloud.android.ui.asynctasks.GetRemoteFileTask import com.owncloud.android.ui.dialog.DeleteBatchTracker +import com.owncloud.android.ui.dialog.ExternalStorageAuthDialogFragment import com.owncloud.android.ui.dialog.SendShareDialog.SendShareDialogDownloader import com.owncloud.android.ui.dialog.SortingOrderDialogFragment.OnSortingOrderListener import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment @@ -208,6 +213,9 @@ class FileDisplayActivity : @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var externalStorageRepository: ExternalStorageRepository + private val viewModel by viewModels { viewModelFactory } private lateinit var binding: FilesBinding @@ -1090,6 +1098,22 @@ class FileDisplayActivity : startMetadataSyncForCurrentDir() } + private suspend fun OCFile.requiresUserProvidedCredentials(): Boolean? { + val result = externalStorageRepository.fetchMounts() + if (result !is ExternalStorageMountsResult.Success) { + Log_OC.w( + TAG, + "Could not fetch external storage mounts to determine if ${this.fileName} requires" + + " user provided credentials." + ) + return null + } + return result.mounts.any { + it.remotePath() == remotePath && + it.config?.authMechanism == AuthMechanism.PasswordUserProvided + } + } + private fun browseUp(listOfFiles: OCFileListFragment) { listOfFiles.onBrowseUp() val currentFile = listOfFiles.currentFile @@ -1536,7 +1560,19 @@ class FileDisplayActivity : ).execute( account, this, - { _, _ -> onSyncFinished() }, + { _, result -> + if (!result.isSuccess && WebdavEntry.MountType.EXTERNAL == folder.mountType) { + // May be an external folder: check if user needs authentication + lifecycleScope.launch { + val showCredentialsDialog = folder.requiresUserProvidedCredentials() + if (showCredentialsDialog ?: false) { + showExternalStorageAuthDialog(folder) + } + } + } else { + onSyncFinished() + } + }, handler, null ) @@ -1544,6 +1580,14 @@ class FileDisplayActivity : fetchRecommendedFilesIfNeeded(ignoreETag, folder) } + private fun showExternalStorageAuthDialog(folder: OCFile) { + if (supportFragmentManager.isStateSaved) return + if (supportFragmentManager.findFragmentByTag(ExternalStorageAuthDialogFragment.TAG) != null) return + + ExternalStorageAuthDialogFragment.newInstance(folder) + .show(supportFragmentManager, ExternalStorageAuthDialogFragment.TAG) + } + private fun syncCurrentFolder(ignoreETag: Boolean, ignoreFocus: Boolean) { val listOfFiles = listOfFilesFragment ?: return if (listOfFiles.isSearchFragment) return diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/ExternalStorageAuthDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/ExternalStorageAuthDialogFragment.kt new file mode 100644 index 000000000000..bf570629ad8f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/ExternalStorageAuthDialogFragment.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.dialog + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.DialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.ExternalStorageAuthDialogBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class ExternalStorageAuthDialogFragment : + DialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private lateinit var binding: ExternalStorageAuthDialogBinding + + private var positiveButton: MaterialButton? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val folder = requireArguments().getParcelableArgument(ARG_FOLDER, OCFile::class.java) + + binding = ExternalStorageAuthDialogBinding.inflate(requireActivity().layoutInflater, null, false) + viewThemeUtils.material.colorTextInputLayout(binding.usernameContainer) + viewThemeUtils.material.colorTextInputLayout(binding.passwordContainer) + binding.username.doAfterTextChanged { updatePositiveButtonState() } + binding.password.doAfterTextChanged { updatePositiveButtonState() } + + val builder = MaterialAlertDialogBuilder(requireActivity()) + .setView(binding.root) + .setTitle(R.string.external_storage_auth_dialog_title) + .setMessage(getString(R.string.external_storage_auth_dialog_message, folder?.fileName)) + .setPositiveButton(R.string.login) { _, _ -> sendCredentials(folder) } + .setNegativeButton(R.string.common_cancel, null) + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.root.context, builder) + return builder.create() + } + + override fun onStart() { + super.onStart() + themeButtons() + updatePositiveButtonState() + } + + private fun themeButtons() { + val dialog = dialog as? AlertDialog ?: return + + positiveButton = (dialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton)?.also { + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it) + } + (dialog.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton)?.let { + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(it) + } + } + + private fun updatePositiveButtonState() { + positiveButton?.isEnabled = !binding.username.text.isNullOrEmpty() && !binding.password.text.isNullOrEmpty() + } + + private fun sendCredentials(folder: OCFile?) { + val result = bundleOf( + RESULT_FOLDER to folder, + RESULT_USERNAME to binding.username.text.toString(), + RESULT_PASSWORD to binding.password.text.toString() + ) + parentFragmentManager.setFragmentResult(REQUEST_KEY, result) + } + + companion object { + const val TAG = "ExternalStorageAuthDialogFragment" + const val REQUEST_KEY = "EXTERNAL_STORAGE_AUTH_REQUEST" + const val RESULT_FOLDER = "FOLDER" + const val RESULT_USERNAME = "USERNAME" + const val RESULT_PASSWORD = "PASSWORD" + + private const val ARG_FOLDER = "FOLDER" + + @JvmStatic + fun newInstance(folder: OCFile): ExternalStorageAuthDialogFragment = ExternalStorageAuthDialogFragment().apply { + arguments = bundleOf(ARG_FOLDER to folder) + } + } +} diff --git a/app/src/main/res/layout/external_storage_auth_dialog.xml b/app/src/main/res/layout/external_storage_auth_dialog.xml new file mode 100644 index 000000000000..810c8ad3ff85 --- /dev/null +++ b/app/src/main/res/layout/external_storage_auth_dialog.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d6f60bf3e1..716282c6fa18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1185,6 +1185,8 @@ Choose storage location Internal storage External storage + Log in to external storage + Enter your credentials to access %1$s Camera Pictures Movies