Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cecd698
feat: show pencil button for editing space public links
joragua Apr 13, 2026
86016a8
refactor: move capabilities handling to SpaceLinksViewModel
joragua Apr 15, 2026
1c7d09e
feat: display correct view to edit the selected space public link
joragua Apr 16, 2026
732c9ab
feat: implement methods and network operation to edit a space public …
joragua Apr 17, 2026
0a49cec
feat: implement methods and network operation to edit the password link
joragua Apr 21, 2026
9f8788a
fix: ellipsize expiration date when there is not enough space
joragua Apr 21, 2026
eb99312
test: create new tests for OCLinksRepositoryTest and OCRemoteLinksDat…
joragua Apr 21, 2026
dbdce72
feat: improve accessibility of save button for editing public links
joragua Apr 22, 2026
0f73214
chore: add calens file
joragua Apr 22, 2026
78b6e0b
refactor: move link name text binding to avoid unnecessary calls
joragua Apr 22, 2026
ed08e8d
feat: add onDestroyView method to prevent binding resource leaks
joragua Apr 22, 2026
dbc222f
style: split function parameters into multiple lines
joragua Apr 22, 2026
6cdc7db
feat: run password update only after successful link edit
joragua Apr 27, 2026
974b515
refactor: move radio buttons listeners to a private fun to avoid Dete…
joragua Apr 27, 2026
6831104
feat: select all link name text when focused
joragua Apr 28, 2026
7673a99
fix: ellipsize expiration date at the end instead of in the middle
joragua Apr 28, 2026
dc7c6a8
fix: run password update before link edit
joragua Apr 29, 2026
fd69c24
refactor: move password listeners to a private fun to avoid Detekt wa…
joragua Apr 29, 2026
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
6 changes: 6 additions & 0 deletions changelog/unreleased/4817
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Edit space link

A new option to edit space public links has been added. It will be only visible for users with proper permissions.

https://github.com/owncloud/android/issues/4756
https://github.com/owncloud/android/pull/4817
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import com.owncloud.android.domain.files.usecases.SortFilesUseCase
import com.owncloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase
import com.owncloud.android.domain.files.usecases.UpdateAlreadyDownloadedFilesPathUseCase
import com.owncloud.android.domain.links.usecases.AddLinkUseCase
import com.owncloud.android.domain.links.usecases.EditLinkUseCase
import com.owncloud.android.domain.links.usecases.EditPasswordLinkUseCase
import com.owncloud.android.domain.links.usecases.RemoveLinkUseCase
import com.owncloud.android.domain.members.usecases.AddMemberUseCase
import com.owncloud.android.domain.members.usecases.EditMemberUseCase
Expand Down Expand Up @@ -321,5 +323,7 @@ val useCaseModule = module {

// Links
factoryOf(::AddLinkUseCase)
factoryOf(::EditLinkUseCase)
factoryOf(::EditPasswordLinkUseCase)
factoryOf(::RemoveLinkUseCase)
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import com.owncloud.android.domain.exceptions.NoConnectionWithServerException
import com.owncloud.android.domain.exceptions.NoNetworkConnectionException
import com.owncloud.android.domain.exceptions.OAuth2ErrorAccessDeniedException
import com.owncloud.android.domain.exceptions.OAuth2ErrorException
import com.owncloud.android.domain.exceptions.PasswordEnforcedException
import com.owncloud.android.domain.exceptions.PayloadTooLongException
import com.owncloud.android.domain.exceptions.QuotaExceededException
import com.owncloud.android.domain.exceptions.RedirectToNonSecureException
Expand Down Expand Up @@ -106,6 +107,7 @@ fun Throwable.parseError(
is NetworkErrorException -> resources.getString(R.string.network_error_message)
is ResourceLockedException -> resources.getString(R.string.resource_locked_error_message)
is PayloadTooLongException -> resources.getString(R.string.uploads_view_upload_status_failed_payload_error)
is PasswordEnforcedException -> resources.getString(R.string.public_link_password_enforced_error)
else -> resources.getString(R.string.common_error_unknown)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ package com.owncloud.android.presentation.capabilities
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import com.owncloud.android.domain.capabilities.model.CapabilityBooleanType
import com.owncloud.android.domain.capabilities.model.OCCapability
import com.owncloud.android.domain.capabilities.usecases.GetCapabilitiesAsLiveDataUseCase
import com.owncloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import com.owncloud.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
import com.owncloud.android.domain.links.model.OCLinkType
import com.owncloud.android.domain.utils.Event
import com.owncloud.android.extensions.ViewModelExt.runUseCaseWithResultAndUseCachedData
import com.owncloud.android.presentation.common.UIResult
Expand Down Expand Up @@ -83,11 +81,4 @@ class CapabilityViewModel(
capabilities?.spaces?.hasMultiplePersonalSpaces == true
}

fun checkPasswordEnforced(selectedPermission: OCLinkType, capabilities: OCCapability?) =
when(selectedPermission) {
OCLinkType.CAN_VIEW -> capabilities?.filesSharingPublicPasswordEnforcedReadOnly == CapabilityBooleanType.TRUE
OCLinkType.CAN_EDIT -> capabilities?.filesSharingPublicPasswordEnforcedReadWrite == CapabilityBooleanType.TRUE
OCLinkType.CREATE_ONLY -> capabilities?.filesSharingPublicPasswordEnforcedUploadOnly == CapabilityBooleanType.TRUE
else -> true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,16 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.owncloud.android.R
import com.owncloud.android.databinding.AddPublicLinkFragmentBinding
import com.owncloud.android.domain.capabilities.model.OCCapability
import com.owncloud.android.domain.links.model.OCLink
import com.owncloud.android.domain.links.model.OCLinkType
import com.owncloud.android.domain.spaces.model.OCSpace
import com.owncloud.android.extensions.collectLatestLifecycleFlow
import com.owncloud.android.extensions.hideSoftKeyboard
import com.owncloud.android.extensions.showErrorInSnackbar
import com.owncloud.android.presentation.capabilities.CapabilityViewModel
import com.owncloud.android.presentation.common.UIResult
import com.owncloud.android.utils.DisplayUtils
import org.koin.androidx.viewmodel.ext.android.activityViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
Expand All @@ -60,15 +57,11 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
requireArguments().getParcelable(ARG_CURRENT_SPACE)
)
}
private val capabilityViewModel: CapabilityViewModel by viewModel {
parametersOf(
accountName
)
}

private var capabilities: OCCapability? = null
private var isPasswordEnforced = true
private var hasPassword = false
private var editMode = false
private var selectedPublicLink: OCLink? = null

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = AddPublicLinkFragmentBinding.inflate(inflater, container, false)
Expand All @@ -77,7 +70,9 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().setTitle(R.string.public_link_create_title)
editMode = requireArguments().getBoolean(ARG_EDIT_MODE)
selectedPublicLink = requireArguments().getParcelable(ARG_SELECTED_PUBLIC_LINK)
requireActivity().setTitle(if (editMode) R.string.public_link_edit_title else R.string.public_link_create_title)

binding.publicLinkPermissions.apply {
canViewPublicLinkRadioButton.tag = OCLinkType.CAN_VIEW
Expand All @@ -94,7 +89,7 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
}
}

hasPassword = it.selectedPassword != null
hasPassword = it.hasPassword
it.selectedPermission?.let {
binding.optionsLayout.isVisible = true
binding.passwordLayout.apply {
Expand All @@ -115,6 +110,7 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
openDatePickerDialog(uiState.selectedExpirationDate)
} else {
expirationDateSwitch.isChecked = true
openDatePickerDialog(null)
}
}
}
Expand All @@ -130,18 +126,6 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
}
}

capabilityViewModel.capabilities.observe(viewLifecycleOwner) { event->
when (val uiResult = event.peekContent()) {
is UIResult.Success -> {
capabilities = uiResult.data
}
is UIResult.Loading -> { }
is UIResult.Error -> {
Timber.e(uiResult.error, "Failed to retrieve server capabilities")
}
}
}

collectLatestLifecycleFlow(spaceLinksViewModel.addLinkResultFlow) { event ->
event?.peekContent()?.let { uiResult ->
when (uiResult) {
Expand All @@ -152,34 +136,55 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
}
}

binding.publicLinkPermissions.apply {
canViewPublicLinkRadioButton.setOnClickListener { selectRadioButton(canViewPublicLinkRadioButton) }
canViewPublicLinkLayout.setOnClickListener { selectRadioButton(canViewPublicLinkRadioButton) }
canEditPublicLinkRadioButton.setOnClickListener { selectRadioButton(canEditPublicLinkRadioButton) }
canEditPublicLinkLayout.setOnClickListener { selectRadioButton(canEditPublicLinkRadioButton) }
secretFileDropPublicLinkRadioButton.setOnClickListener { selectRadioButton(secretFileDropPublicLinkRadioButton) }
secretFileDropPublicLinkLayout.setOnClickListener { selectRadioButton(secretFileDropPublicLinkRadioButton) }
collectLatestLifecycleFlow(spaceLinksViewModel.editLinkResultFlow) { event ->
event?.peekContent()?.let { uiResult ->
when (uiResult) {
is UIResult.Loading -> { }
is UIResult.Success -> parentFragmentManager.popBackStack()
is UIResult.Error -> showErrorInSnackbar(R.string.public_link_edit_failed, uiResult.error)
}
}
}

binding.passwordLayout.apply {
setPasswordButton.setOnClickListener {
showPasswordDialog()
}
removePasswordButton.setOnClickListener {
removePassword()
}
setPasswordSwitch.setOnClickListener {
if (setPasswordSwitch.isChecked) showPasswordDialog() else removePassword()
collectLatestLifecycleFlow(spaceLinksViewModel.editPasswordLinkResultFlow) { event ->
event?.peekContent()?.let { uiResult ->
when (uiResult) {
is UIResult.Loading -> { }
is UIResult.Success -> {
val displayName = binding.publicLinkNameEditText.text.toString().ifEmpty {
getString(R.string.public_link_default_display_name)
}
selectedPublicLink?.let { spaceLinksViewModel.editLink(it.id, displayName) }
parentFragmentManager.popBackStack()
}
is UIResult.Error -> {
showErrorInSnackbar(R.string.public_link_edit_failed, uiResult.error)
}
}
}
}

if (editMode) { bindEditMode() }

bindRadioButtonsListeners()

bindPasswordListeners()

binding.createPublicLinkButton.setOnClickListener {
spaceLinksViewModel.createPublicLink(
binding.publicLinkNameEditText.text.toString().ifEmpty { getString(R.string.public_link_default_display_name) }
)
val displayName = binding.publicLinkNameEditText.text.toString().ifEmpty { getString(R.string.public_link_default_display_name) }
if (editMode) {
selectedPublicLink?.let { spaceLinksViewModel.editPublicLink(it.id, displayName) }
} else {
spaceLinksViewModel.createPublicLink(displayName)
}
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}

override fun onCancelPassword() {
if (!isPasswordEnforced && !hasPassword) {
binding.passwordLayout.setPasswordSwitch.isChecked = false
Expand All @@ -188,10 +193,22 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi

override fun onSetPassword(password: String) {
val normalizedPassword = password.ifBlank { null }
if (!isPasswordEnforced && normalizedPassword == null) {
val hasPassword = normalizedPassword != null
if (!isPasswordEnforced && !hasPassword) {
binding.passwordLayout.setPasswordSwitch.isChecked = false
}
spaceLinksViewModel.onPasswordSelected(normalizedPassword)
spaceLinksViewModel.onPasswordSelected(normalizedPassword, hasPassword)
}

private fun bindRadioButtonsListeners() {
binding.publicLinkPermissions.apply {
canViewPublicLinkRadioButton.setOnClickListener { selectRadioButton(canViewPublicLinkRadioButton) }
canViewPublicLinkLayout.setOnClickListener { selectRadioButton(canViewPublicLinkRadioButton) }
canEditPublicLinkRadioButton.setOnClickListener { selectRadioButton(canEditPublicLinkRadioButton) }
canEditPublicLinkLayout.setOnClickListener { selectRadioButton(canEditPublicLinkRadioButton) }
secretFileDropPublicLinkRadioButton.setOnClickListener { selectRadioButton(secretFileDropPublicLinkRadioButton) }
secretFileDropPublicLinkLayout.setOnClickListener { selectRadioButton(secretFileDropPublicLinkRadioButton) }
}
}

private fun selectRadioButton(selectedRadioButton: RadioButton) {
Expand All @@ -203,14 +220,14 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
selectedRadioButton.isChecked = true
}
val selectedPermission = selectedRadioButton.tag as OCLinkType
isPasswordEnforced = capabilityViewModel.checkPasswordEnforced(selectedPermission, capabilities)
isPasswordEnforced = spaceLinksViewModel.checkPasswordEnforced(selectedPermission)
spaceLinksViewModel.onPermissionSelected(selectedPermission)
}

private fun bindDatePickerDialog(expirationDate: String?) {
binding.expirationDateLayout.expirationDateSwitch.setOnCheckedChangeListener { _, isChecked ->
binding.expirationDateLayout.expirationDateSwitch.setOnClickListener {
hideKeyboardAndClearFocus()
if (isChecked) {
if (binding.expirationDateLayout.expirationDateSwitch.isChecked) {
openDatePickerDialog(expirationDate)
} else {
binding.expirationDateLayout.expirationDateValue.visibility = View.GONE
Expand Down Expand Up @@ -255,6 +272,20 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi
}
}

private fun bindPasswordListeners() {
binding.passwordLayout.apply {
setPasswordButton.setOnClickListener {
showPasswordDialog()
}
removePasswordButton.setOnClickListener {
removePassword()
}
setPasswordSwitch.setOnClickListener {
if (setPasswordSwitch.isChecked) showPasswordDialog() else removePassword()
}
}
}

private fun showPasswordDialog(password: String? = null) {
binding.publicLinkNameEditText.clearFocus()
val dialog = SetPasswordDialogFragment.newInstance(accountName, password, this)
Expand All @@ -263,26 +294,62 @@ class AddPublicLinkFragment: Fragment(), SetPasswordDialogFragment.SetPasswordLi

private fun removePassword() {
hideKeyboardAndClearFocus()
spaceLinksViewModel.onPasswordSelected(null)
spaceLinksViewModel.onPasswordSelected(null, false)
}

private fun hideKeyboardAndClearFocus() {
hideSoftKeyboard()
binding.publicLinkNameEditText.clearFocus()
}

private fun bindEditMode() {
selectedPublicLink?.let {
binding.createPublicLinkButton.apply {
setText(R.string.share_confirm_public_link_button)
contentDescription = getString(R.string.share_confirm_public_link_button)
}

// Do not recreate the edit view after the first iteration
if (spaceLinksViewModel.addPublicLinkUIState.value?.selectedPermission != null) return
Comment thread
jesmrec marked this conversation as resolved.

binding.publicLinkNameEditText.setText(it.displayName)

when (it.type) {
OCLinkType.CAN_VIEW -> selectRadioButton(binding.publicLinkPermissions.canViewPublicLinkRadioButton)
OCLinkType.CAN_EDIT -> selectRadioButton(binding.publicLinkPermissions.canEditPublicLinkRadioButton)
OCLinkType.CREATE_ONLY -> selectRadioButton(binding.publicLinkPermissions.secretFileDropPublicLinkRadioButton)
else -> {}
}

if (it.hasPassword) {
spaceLinksViewModel.onPasswordSelected(password = null, hasPassword = true, wasPasswordChanged = false)
}

it.expirationDateTime?.let { expirationDate ->
spaceLinksViewModel.onExpirationDateSelected(expirationDate)
binding.expirationDateLayout.expirationDateSwitch.isChecked = true
}
}
}

companion object {
private const val DIALOG_SET_PASSWORD = "DIALOG_SET_PASSWORD"
private const val ARG_ACCOUNT_NAME = "ARG_ACCOUNT_NAME"
private const val ARG_CURRENT_SPACE = "ARG_CURRENT_SPACE"
private const val ARG_EDIT_MODE = "ARG_EDIT_MODE"
private const val ARG_SELECTED_PUBLIC_LINK = "ARG_SELECTED_PUBLIC_LINK"

fun newInstance(
accountName: String,
currentSpace: OCSpace
currentSpace: OCSpace,
editMode: Boolean,
selectedPublicLink: OCLink?
): AddPublicLinkFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_NAME, accountName)
putParcelable(ARG_CURRENT_SPACE, currentSpace)
putBoolean(ARG_EDIT_MODE, editMode)
putParcelable(ARG_SELECTED_PUBLIC_LINK, selectedPublicLink)
}
return AddPublicLinkFragment().apply {
arguments = args
Comment thread
jesmrec marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ class SetPasswordDialogFragment: DialogFragment() {
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}

override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
setPasswordListener.onCancelPassword()
Expand Down
Loading
Loading