Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/com/nextcloud/client/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -529,4 +530,7 @@ internal interface ComponentsModule {

@ContributesAndroidInjector
fun albumSharingBottomSheet(): AlbumSharingBottomSheet

@ContributesAndroidInjector
fun externalStorageAuthDialogFragment(): ExternalStorageAuthDialogFragment
}
Original file line number Diff line number Diff line change
@@ -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<ExternalStorageMount>) : ExternalStorageMountsResult

data object Failure : ExternalStorageMountsResult
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,13 +68,16 @@ 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
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -208,6 +213,9 @@ class FileDisplayActivity :
@Inject
lateinit var viewModelFactory: ViewModelFactory

@Inject
lateinit var externalStorageRepository: ExternalStorageRepository

private val viewModel by viewModels<FileDisplayActivityViewModel> { viewModelFactory }

private lateinit var binding: FilesBinding
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1536,14 +1560,34 @@ 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
)

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
45 changes: 45 additions & 0 deletions app/src/main/res/layout/external_storage_auth_dialog.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/dialog_padding">

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:inputType="text|textNoSuggestions" />

</com.google.android.material.textfield.TextInputLayout>

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/standard_half_padding"
android:hint="@string/hint_password">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="password"
android:inputType="textPassword" />

</com.google.android.material.textfield.TextInputLayout>

</LinearLayout>
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,8 @@
<string name="storage_choose_location">Choose storage location</string>
<string name="storage_internal_storage">Internal storage</string>
<string name="storage_external_storage">External storage</string>
<string name="external_storage_auth_dialog_title">Log in to external storage</string>
<string name="external_storage_auth_dialog_message">Enter your credentials to access %1$s</string>
<string name="storage_camera">Camera</string>
<string name="storage_pictures">Pictures</string>
<string name="storage_movies">Movies</string>
Expand Down
Loading