diff --git a/changelog/unreleased/4952 b/changelog/unreleased/4952 new file mode 100644 index 00000000000..b6a01ce8126 --- /dev/null +++ b/changelog/unreleased/4952 @@ -0,0 +1,8 @@ +Enhancement: Create a share over a file or a folder on an oCIS server + +A new option to create a share over a file or a folder on an oCIS has been added. +It will be only visible for users with proper permissions. + +https://github.com/owncloud/android/issues/4875 +https://github.com/owncloud/android/issues/4917 +https://github.com/owncloud/android/pull/4952 diff --git a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt index 8b1eae2e9e5..94e509739fc 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt @@ -90,6 +90,7 @@ import com.owncloud.android.domain.members.usecases.SearchMembersUseCase import com.owncloud.android.domain.roles.usecases.GetRolesAsyncUseCase import com.owncloud.android.domain.server.usecases.GetServerInfoAsyncUseCase import com.owncloud.android.domain.sharing.sharees.GetShareesAsyncUseCase +import com.owncloud.android.domain.sharing.shares.usecases.AddGraphShareAsyncUseCase import com.owncloud.android.domain.sharing.shares.usecases.CreatePrivateShareAsyncUseCase import com.owncloud.android.domain.sharing.shares.usecases.CreatePublicShareAsyncUseCase import com.owncloud.android.domain.sharing.shares.usecases.DeleteShareAsyncUseCase @@ -229,6 +230,7 @@ val useCaseModule = module { factoryOf(::UnsetFilesAsAvailableOfflineUseCase) // Sharing + factoryOf(::AddGraphShareAsyncUseCase) factoryOf(::CreatePrivateShareAsyncUseCase) factoryOf(::CreatePublicShareAsyncUseCase) factoryOf(::DeleteShareAsyncUseCase) diff --git a/owncloudApp/src/main/java/com/owncloud/android/extensions/AddMemberFragmentBindingExt.kt b/owncloudApp/src/main/java/com/owncloud/android/extensions/AddMemberFragmentBindingExt.kt new file mode 100644 index 00000000000..bc469678838 --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/extensions/AddMemberFragmentBindingExt.kt @@ -0,0 +1,56 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.extensions + +import androidx.core.view.isVisible +import com.owncloud.android.R +import com.owncloud.android.databinding.AddMemberFragmentBinding +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.domain.members.model.OCMemberType +import com.owncloud.android.presentation.spaces.members.SpaceRolesAdapter + +fun AddMemberFragmentBinding.showOrHideEmptyView(hasMembers: Boolean, searchMinLength: Int) { + membersRecyclerView.isVisible = hasMembers + emptyDataParent.apply { + val shouldShow = !hasMembers && searchBar.query.length >= searchMinLength + root.isVisible = shouldShow + if (shouldShow) { + listEmptyDatasetIcon.setImageResource(R.drawable.ic_share_generic_white) + listEmptyDatasetTitle.setText(R.string.members_search_failed) + listEmptyDatasetSubTitle.setText(R.string.members_search_empty) + } + } +} + +fun AddMemberFragmentBinding.bindSelectedMember(member: OCMember) { + selectedMemberLayout.apply { + memberIcon.setImageResource(if (member.type == OCMemberType.GROUP) R.drawable.ic_group else R.drawable.ic_user) + memberName.text = member.displayName + memberRole.text = member.surname + } +} + +fun AddMemberFragmentBinding.bindRoles(rolesAdapter: SpaceRolesAdapter, selectedRoleId: String?) { + selectedRoleId?.let { + inviteMemberButton.isEnabled = true + rolesAdapter.setSelectedRole(it) + } +} diff --git a/owncloudApp/src/main/java/com/owncloud/android/extensions/FragmentExt.kt b/owncloudApp/src/main/java/com/owncloud/android/extensions/FragmentExt.kt index 12a6d96b15b..9f642898940 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/extensions/FragmentExt.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/extensions/FragmentExt.kt @@ -23,8 +23,10 @@ package com.owncloud.android.extensions import android.app.AlertDialog +import android.app.DatePickerDialog import android.content.Context import android.content.DialogInterface +import android.icu.util.Calendar import android.view.Menu import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View @@ -36,10 +38,15 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar import com.owncloud.android.R +import com.owncloud.android.databinding.AddMemberFragmentBinding import com.owncloud.android.domain.appregistry.model.AppRegistryProvider +import com.owncloud.android.utils.DisplayUtils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone fun Fragment.showErrorInSnackbar(genericErrorMessageId: Int, throwable: Throwable?) = throwable?.let { @@ -111,6 +118,61 @@ fun Fragment.collectLatestLifecycleFlow( } } +fun Fragment.bindDatePickerDialog( + binding: AddMemberFragmentBinding, + expirationDate: String?, + onExpirationDateSelected: (String?) -> Unit, +) { + binding.expirationDateLayout.expirationDateSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + openDatePickerDialog(binding, expirationDate, onExpirationDateSelected) + } else { + binding.expirationDateLayout.expirationDateValue.visibility = View.GONE + onExpirationDateSelected(null) + } + } +} + +fun Fragment.openDatePickerDialog( + binding: AddMemberFragmentBinding, + expirationDate: String?, + onExpirationDateSelected: (String?) -> Unit, +) { + val calendar = Calendar.getInstance() + val formatter = SimpleDateFormat(DisplayUtils.DATE_FORMAT_ISO, Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + expirationDate?.let { + calendar.time = formatter.parse(it) + } + + DatePickerDialog( + requireContext(), + { _, selectedYear, selectedMonth, selectedDay -> + calendar.set(selectedYear, selectedMonth, selectedDay, 23, 59, 59) + calendar.set(Calendar.MILLISECOND, 999) + val isoExpirationDate = formatter.format(calendar.time) + onExpirationDateSelected(isoExpirationDate) + binding.expirationDateLayout.expirationDateValue.apply { + visibility = View.VISIBLE + text = DisplayUtils.displayDateToHumanReadable(isoExpirationDate) + } + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ).apply { + datePicker.minDate = Calendar.getInstance().timeInMillis + show() + setOnCancelListener { + if (expirationDate == null) { + binding.expirationDateLayout.expirationDateSwitch.isChecked = false + } + } + } +} + fun Fragment.addOpenInWebMenuOptions( menu: Menu, openInWebProviders: Map = emptyMap(), diff --git a/owncloudApp/src/main/java/com/owncloud/android/extensions/ThrowableExt.kt b/owncloudApp/src/main/java/com/owncloud/android/extensions/ThrowableExt.kt index 5b7dd781266..5e407617373 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/extensions/ThrowableExt.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/extensions/ThrowableExt.kt @@ -28,6 +28,7 @@ import com.owncloud.android.domain.exceptions.AccountNotTheSameException import com.owncloud.android.domain.exceptions.BadOcVersionException import com.owncloud.android.domain.exceptions.ConflictException import com.owncloud.android.domain.exceptions.ConflictMemberException +import com.owncloud.android.domain.exceptions.ConflictShareException import com.owncloud.android.domain.exceptions.CopyIntoDescendantException import com.owncloud.android.domain.exceptions.CopyIntoSameFolderException import com.owncloud.android.domain.exceptions.FileAlreadyExistsException @@ -75,6 +76,7 @@ fun Throwable.parseError( is BadOcVersionException -> resources.getString(R.string.auth_bad_oc_version_title) is ConflictException -> resources.getString(R.string.error_conflict) is ConflictMemberException -> resources.getString(R.string.members_add_conflict_error) + is ConflictShareException -> resources.getString(R.string.share_add_conflict_error) is CopyIntoDescendantException -> resources.getString(R.string.copy_file_invalid_into_descendent) is CopyIntoSameFolderException -> resources.getString(R.string.copy_file_invalid_overwrite) is FileAlreadyExistsException -> resources.getString(R.string.file_already_exists) diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt new file mode 100644 index 00000000000..0b9b9620b8a --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt @@ -0,0 +1,230 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.presentation.sharing + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.databinding.AddMemberFragmentBinding +import com.owncloud.android.domain.files.model.OCFile +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.presentation.common.UIResult +import com.owncloud.android.domain.roles.model.OCRole +import com.owncloud.android.extensions.bindDatePickerDialog +import com.owncloud.android.extensions.bindRoles +import com.owncloud.android.extensions.bindSelectedMember +import com.owncloud.android.extensions.collectLatestLifecycleFlow +import com.owncloud.android.extensions.openDatePickerDialog +import com.owncloud.android.extensions.showErrorInSnackbar +import com.owncloud.android.extensions.showOrHideEmptyView +import com.owncloud.android.presentation.spaces.members.SearchMembersAdapter +import com.owncloud.android.presentation.spaces.members.SpaceRolesAdapter +import com.owncloud.android.utils.DisplayUtils +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.core.parameter.parametersOf +import timber.log.Timber + +class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdapterListener { + private var _binding: AddMemberFragmentBinding? = null + private val binding get() = _binding!! + + private val graphShareViewModel by activityViewModel { + parametersOf( + requireArguments().getString(ARG_ACCOUNT_NAME), + requireArguments().getParcelable(ARG_FILE) + ) + } + + private lateinit var searchMembersAdapter: SearchMembersAdapter + private lateinit var rolesAdapter: SpaceRolesAdapter + private lateinit var recyclerView: RecyclerView + private lateinit var roles: List + + private var searchMinLength = DEFAULT_SEARCH_MIN_LENGTH + private var currentUserId: String? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AddMemberFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + searchMembersAdapter = SearchMembersAdapter(this) + recyclerView = binding.membersRecyclerView + recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = searchMembersAdapter + } + + subscribeToViewModels() + + rolesAdapter = SpaceRolesAdapter(onRoleSelected = { + binding.inviteMemberButton.isEnabled = true + graphShareViewModel.onRoleSelected(it) + }) + binding.rolesRecyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = rolesAdapter + } + rolesAdapter.setRoles(roles) + + binding.searchBar.apply { + if (savedInstanceState == null) { requestFocus() } + setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean = true + + override fun onQueryTextChange(newText: String): Boolean { + if (newText.length >= searchMinLength) { + graphShareViewModel.searchMembers(newText) + } else { + graphShareViewModel.clearSearch() + } + return true + } + }) + } + } + + override fun onMemberClick(member: OCMember) { + graphShareViewModel.onMemberSelected(member) + } + + private fun subscribeToViewModels() { + val currentPermissions = (graphShareViewModel.shares.value?.peekContent() as? UIResult.Success)?.data + roles = currentPermissions?.roles ?: emptyList() + val currentShares = currentPermissions?.members ?: emptyList() + searchMinLength = graphShareViewModel.capabilities?.filesSharingSearchMinLength ?: DEFAULT_SEARCH_MIN_LENGTH + + collectLatestLifecycleFlow(graphShareViewModel.userId) { event -> + event?.let { + when (val uiResult = event.peekContent()) { + is UIResult.Success -> { + uiResult.data?.let { currentUserId = it } + } + is UIResult.Loading -> { } + is UIResult.Error -> { + Timber.e(uiResult.error, "Failed to retrieve user id") + } + } + } + } + + collectLatestLifecycleFlow(graphShareViewModel.members) { uiState -> + if (uiState.isLoading) { + binding.indeterminateProgressBar.visibility = View.VISIBLE + binding.emptyDataParent.root.visibility = View.GONE + binding.membersRecyclerView.visibility = View.GONE + } else { + binding.indeterminateProgressBar.visibility = View.GONE + val sharedMemberIds = currentShares.mapTo(HashSet()) { it.memberId } + val listOfMembersFiltered = uiState.members.filterNot { member -> + member.id == currentUserId || member.id in sharedMemberIds + } + val hasMembers = listOfMembersFiltered.isNotEmpty() + binding.showOrHideEmptyView(hasMembers, searchMinLength) + if (hasMembers) searchMembersAdapter.setMembers(listOfMembersFiltered) + uiState.error?.let { + Timber.e(uiState.error, "Failed to retrieve available users and groups") + showErrorInSnackbar(R.string.members_search_failed, uiState.error) + } + } + } + + collectLatestLifecycleFlow(graphShareViewModel.addShareUIState) { uiState -> + uiState?.let { + binding.apply { + searchMemberLayout.visibility = View.GONE + addMemberLayout.visibility = View.VISIBLE + inviteMemberButton.visibility = View.VISIBLE + inviteMemberButton.text = getString(R.string.action_share) + inviteMemberButton.contentDescription = getString(R.string.content_description_create_share_button) + } + it.selectedMember?.let { member -> + binding.bindSelectedMember(member) + } + it.selectedExpirationDate?.let { expirationDate -> + binding.expirationDateLayout.expirationDateValue.apply { + visibility = View.VISIBLE + text = DisplayUtils.displayDateToHumanReadable(expirationDate) + } + } + binding.bindRoles(rolesAdapter, uiState.selectedRole?.id) + bindDatePickerDialog(binding, uiState.selectedExpirationDate, graphShareViewModel::onExpirationDateSelected) + + binding.expirationDateLayout.apply { + expirationDateLayout.setOnClickListener { + if (uiState.selectedExpirationDate != null) { + openDatePickerDialog(binding, uiState.selectedExpirationDate, graphShareViewModel::onExpirationDateSelected) + } else { + expirationDateSwitch.isChecked = true + } + } + } + + binding.inviteMemberButton.setOnClickListener { + uiState.selectedMember?.let { selectedMember -> + uiState.selectedRole?.let { selectedRole -> + graphShareViewModel.addGraphShare(selectedMember, selectedRole.id) + } + } + } + } + } + + collectLatestLifecycleFlow(graphShareViewModel.addShareResultFlow) { event -> + event?.peekContent()?.let { uiResult -> + when (uiResult) { + is UIResult.Loading -> { } + is UIResult.Success -> parentFragmentManager.popBackStack() + is UIResult.Error -> showErrorInSnackbar(R.string.share_add_failed, uiResult.error) + } + } + } + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME" + private const val DEFAULT_SEARCH_MIN_LENGTH = 3 + + fun newInstance(file: OCFile, accountName: String): AddGraphShareFragment { + val args = Bundle().apply { + putParcelable(ARG_FILE, file) + putString(ARG_ACCOUNT_NAME, accountName) + } + return AddGraphShareFragment().apply { + arguments = args + } + } + } +} diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt index 792f7f50004..eb7ab30a670 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareFragment.kt @@ -20,6 +20,7 @@ package com.owncloud.android.presentation.sharing +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -33,8 +34,9 @@ import com.owncloud.android.domain.files.model.OCFile import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.extensions.showErrorInSnackbar +import com.owncloud.android.extensions.showMessageInSnackbar import com.owncloud.android.presentation.common.UIResult -import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.core.parameter.parametersOf import timber.log.Timber @@ -42,7 +44,7 @@ class GraphShareFragment : Fragment() { private var _binding: MembersFragmentBinding? = null private val binding get() = _binding!! - private val graphShareViewModel by viewModel { + private val graphShareViewModel by activityViewModel { parametersOf( requireArguments().getString(ARG_ACCOUNT_NAME), requireArguments().getParcelable(ARG_FILE) @@ -52,6 +54,7 @@ class GraphShareFragment : Fragment() { private lateinit var graphSharesAdapter: GraphSharesAdapter private var roles: List = emptyList() + private var listener: GraphShareFragmentListener? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = MembersFragmentBinding.inflate(inflater, container, false) @@ -72,9 +75,29 @@ class GraphShareFragment : Fragment() { graphShareViewModel.getGraphShares() } + val file = requireArguments().getParcelable(ARG_FILE) + val accountName = requireArguments().getString(ARG_ACCOUNT_NAME) + binding.addMemberButton.isVisible = file?.hasResharePermission ?: false + binding.addMemberButton.setOnClickListener { + if (file != null && accountName != null) { + graphShareViewModel.resetViewModel() + listener?.addGraphShare(file = file, accountName = accountName) + } + } + subscribeToViewModels() } + override fun onAttach(context: Context) { + super.onAttach(context) + try { + listener = context as GraphShareFragmentListener? + } catch (e: ClassCastException) { + Timber.e(e, "The activity attached does not implement GraphShareFragmentListener") + throw ClassCastException(activity.toString() + " must implement GraphShareFragmentListener") + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null @@ -83,6 +106,7 @@ class GraphShareFragment : Fragment() { private fun subscribeToViewModels() { observeRoles() observeShares() + observeAddShareResult() } private fun observeRoles() { @@ -129,6 +153,25 @@ class GraphShareFragment : Fragment() { } } + private fun observeAddShareResult() { + collectLatestLifecycleFlow(graphShareViewModel.addShareResultFlow) { event -> + event?.peekContent()?.let { uiResult -> + when (uiResult) { + is UIResult.Loading -> { } + is UIResult.Success -> { + showMessageInSnackbar(getString(R.string.share_add_correctly)) + graphShareViewModel.resetViewModel() + } + is UIResult.Error -> { } + } + } + } + } + + interface GraphShareFragmentListener { + fun addGraphShare(file: OCFile, accountName: String) + } + companion object { private const val ARG_FILE = "FILE" private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME" diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareViewModel.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareViewModel.kt index d332f781caf..4b683896144 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareViewModel.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/GraphShareViewModel.kt @@ -21,23 +21,40 @@ package com.owncloud.android.presentation.sharing import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.owncloud.android.domain.UseCaseResult +import com.owncloud.android.domain.capabilities.model.OCCapability +import com.owncloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import com.owncloud.android.domain.exceptions.IncompleteFileDataException import com.owncloud.android.domain.files.model.OCFile +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.domain.members.usecases.SearchMembersUseCase import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.domain.roles.usecases.GetRolesAsyncUseCase +import com.owncloud.android.domain.sharing.shares.usecases.AddGraphShareAsyncUseCase import com.owncloud.android.domain.sharing.shares.usecases.GetGraphSharesAsyncUseCase import com.owncloud.android.domain.sharing.shares.model.OCPermissions +import com.owncloud.android.domain.user.usecases.GetUserIdAsyncUseCase import com.owncloud.android.domain.utils.Event import com.owncloud.android.extensions.ViewModelExt.runUseCaseWithResult import com.owncloud.android.presentation.common.UIResult import com.owncloud.android.providers.CoroutinesDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class GraphShareViewModel( + private val addGraphShareAsyncUseCase: AddGraphShareAsyncUseCase, private val getRolesAsyncUseCase: GetRolesAsyncUseCase, private val getGraphSharesAsyncUseCase: GetGraphSharesAsyncUseCase, + private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase, + private val searchMembersUseCase: SearchMembersUseCase, + private val getUserIdAsyncUseCase: GetUserIdAsyncUseCase, private val accountName: String, private val file: OCFile, private val coroutineDispatcherProvider: CoroutinesDispatcherProvider, @@ -49,6 +66,21 @@ class GraphShareViewModel( private val _shares = MutableStateFlow>?>(null) val shares: StateFlow>?> = _shares + private val _userId = MutableStateFlow>?>(null) + val userId: StateFlow>?> = _userId + + private val _members: MutableSharedFlow = MutableSharedFlow() + val members: SharedFlow = _members + + private val _addShareUIState = MutableStateFlow(null) + val addShareUIState: StateFlow = _addShareUIState + + private val _addShareResultFlow = MutableStateFlow>?>(null) + val addShareResultFlow: StateFlow>?> = _addShareResultFlow + + private var searchJob: Job? = null + var capabilities: OCCapability? = null + init { runUseCaseWithResult( coroutineDispatcher = coroutineDispatcherProvider.io, @@ -56,6 +88,16 @@ class GraphShareViewModel( useCase = getRolesAsyncUseCase, useCaseParams = GetRolesAsyncUseCase.Params(accountName = accountName), ) + runUseCaseWithResult( + coroutineDispatcher = coroutineDispatcherProvider.io, + showLoading = false, + flow = _userId, + useCase = getUserIdAsyncUseCase, + useCaseParams = GetUserIdAsyncUseCase.Params(accountName = accountName) + ) + viewModelScope.launch(coroutineDispatcherProvider.io) { + capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName)) + } } fun getGraphShares() { @@ -78,4 +120,78 @@ class GraphShareViewModel( ) ) } + + fun addGraphShare(member: OCMember, roleId: String) { + val spaceId = file.spaceId + val itemId = file.remoteId + if (spaceId == null || itemId == null) { + _addShareResultFlow.update { Event(UIResult.Error(error = IncompleteFileDataException())) } + return + } + + runUseCaseWithResult( + coroutineDispatcher = coroutineDispatcherProvider.io, + flow = _addShareResultFlow, + useCase = addGraphShareAsyncUseCase, + useCaseParams = AddGraphShareAsyncUseCase.Params( + accountName = accountName, + spaceId = spaceId, + itemId = itemId, + member = member, + roleId = roleId, + expirationDate = _addShareUIState.value?.selectedExpirationDate + ) + ) + } + + fun searchMembers(query: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch(coroutineDispatcherProvider.io) { + delay(SEARCH_DELAY_MS) + _members.emit(MembersUIState(members = emptyList(), isLoading = true, error = null)) + when (val result = searchMembersUseCase(SearchMembersUseCase.Params(accountName, query))) { + is UseCaseResult.Success -> _members.emit(MembersUIState(members = result.data, isLoading = false, error = null)) + is UseCaseResult.Error -> _members.emit(MembersUIState(members = emptyList(), isLoading = false, error = result.getThrowableOrNull())) + } + } + } + + fun clearSearch() { + viewModelScope.launch(coroutineDispatcherProvider.io) { + _members.emit(MembersUIState(members = emptyList(), isLoading = false, error = null)) + } + } + + fun onMemberSelected(member: OCMember) { + _addShareUIState.value = AddShareUIState(selectedMember = member) + } + + fun onRoleSelected(role: OCRole) { + _addShareUIState.update { it?.copy(selectedRole = role) } + } + + fun onExpirationDateSelected(expirationDate: String?) { + _addShareUIState.update { it?.copy(selectedExpirationDate = expirationDate) } + } + + fun resetViewModel() { + _addShareUIState.value = null + _addShareResultFlow.value = null + } + + data class MembersUIState( + val members: List, + val isLoading: Boolean, + val error: Throwable? + ) + + data class AddShareUIState( + val selectedMember: OCMember? = null, + val selectedRole: OCRole? = null, + val selectedExpirationDate: String? = null + ) + + companion object { + private const val SEARCH_DELAY_MS = 500L + } } diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt index 72b8c5faec7..cf66eed3b8c 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/ShareActivity.kt @@ -63,7 +63,7 @@ import timber.log.Timber /** * Activity for sharing files */ -class ShareActivity : FileActivity(), ShareFragmentListener { +class ShareActivity : FileActivity(), ShareFragmentListener, GraphShareFragment.GraphShareFragmentListener { private val shareViewModel: ShareViewModel by viewModel { parametersOf( file.remotePath, @@ -78,7 +78,7 @@ class ShareActivity : FileActivity(), ShareFragmentListener { super.onCreate(savedInstanceState) if (capabilityViewModel.isOcisServer()) { - setupOcisLayout() + setupOcisLayout(savedInstanceState) } else { setupLegacyLayout(savedInstanceState) } @@ -88,12 +88,12 @@ class ShareActivity : FileActivity(), ShareFragmentListener { observeShareDeletion() } - private fun setupOcisLayout() { + private fun setupOcisLayout(savedInstanceState: Bundle?) { val binding = MembersActivityBinding.inflate(layoutInflater) setContentView(binding.root) setupToolbar(binding.root) setupFileHeader(binding) - if (file != null && account != null) { + if (savedInstanceState == null && file != null && account != null) { supportFragmentManager.transaction { replace(R.id.members_fragment_container, GraphShareFragment.newInstance(file, account!!.name), TAG_GRAPH_SHARE_FRAGMENT) } @@ -110,6 +110,16 @@ class ShareActivity : FileActivity(), ShareFragmentListener { } } + override fun addGraphShare(file: OCFile, accountName: String) { + val addGraphShareFragment = AddGraphShareFragment.newInstance(file, accountName) + val transaction = supportFragmentManager.beginTransaction() + transaction.apply { + replace(R.id.members_fragment_container, addGraphShareFragment, TAG_ADD_GRAPH_SHARE_FRAGMENT) + addToBackStack(null) + commit() + } + } + private fun setupToolbar(root: View) { adaptInfiniteEdges(root) setupStandardToolbar(title = null, displayHomeAsUpEnabled = true, homeButtonEnabled = true, displayShowTitleEnabled = true) @@ -372,6 +382,7 @@ class ShareActivity : FileActivity(), ShareFragmentListener { companion object { const val TAG_SHARE_FRAGMENT = "SHARE_FRAGMENT" const val TAG_GRAPH_SHARE_FRAGMENT = "GRAPH_SHARE_FRAGMENT" + const val TAG_ADD_GRAPH_SHARE_FRAGMENT = "ADD_GRAPH_SHARE_FRAGMENT" const val TAG_SEARCH_FRAGMENT = "SEARCH_USER_AND_GROUPS_FRAGMENT" const val TAG_EDIT_SHARE_FRAGMENT = "EDIT_SHARE_FRAGMENT" const val TAG_PUBLIC_SHARE_DIALOG_FRAGMENT = "PUBLIC_SHARE_DIALOG_FRAGMENT" diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceRolesAdapter.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceRolesAdapter.kt index d02d5ab9419..5b0a01a1186 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceRolesAdapter.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceRolesAdapter.kt @@ -69,7 +69,11 @@ class SpaceRolesAdapter( roleIcon.setImageResource( when(OCRoleType.parseFromId(role.id)) { OCRoleType.CAN_VIEW -> R.drawable.ic_viewer_role + OCRoleType.CAN_VIEW_SHARE -> R.drawable.ic_viewer_role OCRoleType.CAN_EDIT -> R.drawable.ic_lead_pencil_grey + OCRoleType.CAN_EDIT_SHARE -> R.drawable.ic_lead_pencil_grey + OCRoleType.CAN_EDIT_FOLDER_SHARE -> R.drawable.ic_uploads + OCRoleType.CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE -> R.drawable.ic_lead_pencil_grey OCRoleType.CAN_MANAGE -> R.drawable.ic_share_generic OCRoleType.UNKNOWN_ROLE -> R.drawable.ic_user } diff --git a/owncloudApp/src/main/res/drawable/ic_uploads.xml b/owncloudApp/src/main/res/drawable/ic_uploads.xml index d6339722a17..11e906a05bb 100644 --- a/owncloudApp/src/main/res/drawable/ic_uploads.xml +++ b/owncloudApp/src/main/res/drawable/ic_uploads.xml @@ -2,7 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24.0" - android:viewportHeight="24.0"> + android:viewportHeight="24.0" + android:tint="?attr/colorControlNormal"> diff --git a/owncloudApp/src/main/res/layout/add_member_fragment.xml b/owncloudApp/src/main/res/layout/add_member_fragment.xml index b4326bcfca8..cc0d4051291 100644 --- a/owncloudApp/src/main/res/layout/add_member_fragment.xml +++ b/owncloudApp/src/main/res/layout/add_member_fragment.xml @@ -135,7 +135,7 @@ android:id="@+id/roles_recycler_view" android:layout_width="match_parent" android:layout_height="wrap_content" - android:minHeight="150dp" + android:minHeight="100dp" android:scrollbars="vertical" android:visibility="visible" /> diff --git a/owncloudApp/src/main/res/layout/expiration_date_layout.xml b/owncloudApp/src/main/res/layout/expiration_date_layout.xml index c17c486945d..b389a04d1e0 100644 --- a/owncloudApp/src/main/res/layout/expiration_date_layout.xml +++ b/owncloudApp/src/main/res/layout/expiration_date_layout.xml @@ -41,7 +41,9 @@ + android:layout_height="wrap_content" + android:paddingTop="@dimen/standard_quarter_padding" + android:paddingBottom="@dimen/standard_quarter_padding"> Users and Groups No data shared with users yet Shares could not be refreshed + Share created correctly + Share could not be created + Is already shared with this user/group Public links Create link share Edit link share @@ -716,6 +719,7 @@ Expires %1$s Add new member Invite member + Create share Expiration date Remove member %1$s Edit member %1$s diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java index 5fdeeabe913..9eea6631194 100644 --- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java +++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/common/operations/RemoteOperationResult.java @@ -576,6 +576,7 @@ public enum ResultCode { INVALID_OVERWRITE, CONFLICT, CONFLICT_MEMBER, + CONFLICT_SHARE, OAUTH2_ERROR, SYNC_CONFLICT, LOCAL_STORAGE_FULL, diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/AddRemoteGraphShareOperation.kt b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/AddRemoteGraphShareOperation.kt new file mode 100644 index 00000000000..0c4177f1d8d --- /dev/null +++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/AddRemoteGraphShareOperation.kt @@ -0,0 +1,105 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.lib.resources.shares + +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.http.HttpConstants +import com.owncloud.android.lib.common.http.HttpConstants.CONTENT_TYPE_JSON +import com.owncloud.android.lib.common.http.methods.nonwebdav.PostMethod +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.net.URL + +class AddRemoteGraphShareOperation( + private val spaceId: String, + private val itemId: String, + private val memberId: String, + private val memberType: String, + private val roleId: String, + private val expirationDate: String? +) : RemoteOperation() { + override fun run(client: OwnCloudClient): RemoteOperationResult { + var result: RemoteOperationResult + try { + val uriBuilder = client.baseUri.buildUpon().apply { + appendEncodedPath(GRAPH_API_DRIVES_PATH) + appendEncodedPath(spaceId) + appendEncodedPath(GRAPH_API_ITEMS_PATH) + appendEncodedPath(itemId) + appendEncodedPath(GRAPH_API_INVITE_PATH) + } + + val recipients = JSONObject().apply { + put(MEMBER_TYPE_BODY_PARAM, memberType) + put(MEMBER_ID_BODY_PARAM, memberId) + } + + val requestBody = JSONObject().apply { + expirationDate?.let { put(EXPIRATION_DATE_BODY_PARAM, it) } + put(RECIPIENTS_BODY_PARAM, JSONArray().put(recipients)) + put(ROLES_BODY_PARAM, JSONArray().put(roleId)) + }.toString().toRequestBody(CONTENT_TYPE_JSON.toMediaType()) + + val postMethod = PostMethod(URL(uriBuilder.build().toString()), requestBody) + + val status = client.executeHttpMethod(postMethod) + + val response = postMethod.getResponseBodyAsString() + + when (status) { + HttpConstants.HTTP_OK -> { + Timber.d("Successful response: $response") + result = RemoteOperationResult(ResultCode.OK) + Timber.d("Add graph share operation completed") + } + HttpConstants.HTTP_CONFLICT -> { + result = RemoteOperationResult(ResultCode.CONFLICT_SHARE) + Timber.e("Conflict response while adding a graph share; status code: $status, response: $response") + } + else -> { + result = RemoteOperationResult(postMethod) + Timber.e("Failed response while adding a graph share; status code: $status, response: $response") + } + } + } catch (e: Exception) { + result = RemoteOperationResult(e) + Timber.e(e, "Exception while adding a graph share") + } + return result + } + + companion object { + private const val GRAPH_API_DRIVES_PATH = "graph/v1beta1/drives/" + private const val GRAPH_API_ITEMS_PATH = "items" + private const val GRAPH_API_INVITE_PATH = "invite" + private const val EXPIRATION_DATE_BODY_PARAM = "expirationDateTime" + private const val RECIPIENTS_BODY_PARAM = "recipients" + private const val MEMBER_TYPE_BODY_PARAM = "@libre.graph.recipient.type" + private const val MEMBER_ID_BODY_PARAM = "objectId" + private const val ROLES_BODY_PARAM = "roles" + } +} diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/ShareService.kt b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/ShareService.kt index b81cb8fcab5..e9a4f0c4455 100644 --- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/ShareService.kt +++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/ShareService.kt @@ -41,6 +41,15 @@ interface ShareService : Service { fun getGraphShares(spaceId: String, itemId: String): RemoteOperationResult + fun addGraphShare( + spaceId: String, + itemId: String, + memberId: String, + memberType: String, + roleId: String, + expirationDate: String? + ): RemoteOperationResult + fun insertShare( remoteFilePath: String, shareType: ShareType, diff --git a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/implementation/OCShareService.kt b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/implementation/OCShareService.kt index fa9fc9ebf71..f22bdc472e3 100644 --- a/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/implementation/OCShareService.kt +++ b/owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/services/implementation/OCShareService.kt @@ -28,6 +28,7 @@ package com.owncloud.android.lib.resources.shares.services.implementation import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.shares.AddRemoteGraphShareOperation import com.owncloud.android.lib.resources.shares.CreateRemoteShareOperation import com.owncloud.android.lib.resources.shares.GetRemoteGraphSharesForFileOperation import com.owncloud.android.lib.resources.shares.GetRemoteSharesForFileOperation @@ -52,6 +53,16 @@ class OCShareService(override val client: OwnCloudClient) : ShareService { override fun getGraphShares(spaceId: String, itemId: String): RemoteOperationResult = GetRemoteGraphSharesForFileOperation(spaceId, itemId).execute(client) + override fun addGraphShare( + spaceId: String, + itemId: String, + memberId: String, + memberType: String, + roleId: String, + expirationDate: String? + ): RemoteOperationResult = + AddRemoteGraphShareOperation(spaceId, itemId, memberId, memberType, roleId, expirationDate).execute(client) + override fun insertShare( remoteFilePath: String, shareType: ShareType, diff --git a/owncloudData/src/main/java/com/owncloud/android/data/RemoteOperationHandler.kt b/owncloudData/src/main/java/com/owncloud/android/data/RemoteOperationHandler.kt index e249ca38e93..4cfd0d2ef61 100644 --- a/owncloudData/src/main/java/com/owncloud/android/data/RemoteOperationHandler.kt +++ b/owncloudData/src/main/java/com/owncloud/android/data/RemoteOperationHandler.kt @@ -30,6 +30,7 @@ import com.owncloud.android.domain.exceptions.BadOcVersionException import com.owncloud.android.domain.exceptions.CancelledException import com.owncloud.android.domain.exceptions.ConflictException import com.owncloud.android.domain.exceptions.ConflictMemberException +import com.owncloud.android.domain.exceptions.ConflictShareException import com.owncloud.android.domain.exceptions.CopyIntoDescendantException import com.owncloud.android.domain.exceptions.DelayedForWifiException import com.owncloud.android.domain.exceptions.FileNotFoundException @@ -120,6 +121,7 @@ private fun handleRemoteOperationResult( RemoteOperationResult.ResultCode.INVALID_OVERWRITE -> throw InvalidOverwriteException() RemoteOperationResult.ResultCode.CONFLICT -> throw ConflictException() RemoteOperationResult.ResultCode.CONFLICT_MEMBER -> throw ConflictMemberException() + RemoteOperationResult.ResultCode.CONFLICT_SHARE -> throw ConflictShareException() RemoteOperationResult.ResultCode.SYNC_CONFLICT -> throw SyncConflictException() RemoteOperationResult.ResultCode.LOCAL_STORAGE_FULL -> throw LocalStorageFullException() RemoteOperationResult.ResultCode.LOCAL_STORAGE_NOT_MOVED -> throw LocalStorageNotMovedException() diff --git a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/RemoteShareDataSource.kt b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/RemoteShareDataSource.kt index e193d8c3f92..eb9d4178aa4 100644 --- a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/RemoteShareDataSource.kt +++ b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/RemoteShareDataSource.kt @@ -21,6 +21,7 @@ package com.owncloud.android.data.sharing.shares.datasources +import com.owncloud.android.domain.members.model.OCMember import com.owncloud.android.domain.sharing.shares.model.OCShare import com.owncloud.android.domain.sharing.shares.model.ShareType import com.owncloud.android.domain.sharing.shares.model.OCPermissions @@ -40,6 +41,15 @@ interface RemoteShareDataSource { itemId: String ): OCPermissions + fun addGraphShare( + accountName: String, + spaceId: String, + itemId: String, + member: OCMember, + roleId: String, + expirationDate: String? + ) + fun insert( remoteFilePath: String, shareType: ShareType, diff --git a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSource.kt b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSource.kt index 427ba766347..ddb0fa5fe78 100644 --- a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSource.kt +++ b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSource.kt @@ -27,6 +27,8 @@ import com.owncloud.android.data.sharing.shares.datasources.RemoteShareDataSourc import com.owncloud.android.data.sharing.shares.datasources.mapper.RemoteShareMapper import com.owncloud.android.domain.links.model.OCLink import com.owncloud.android.domain.links.model.OCLinkType +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.domain.members.model.OCMemberType import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.domain.sharing.shares.model.OCShare import com.owncloud.android.domain.sharing.shares.model.ShareType @@ -67,6 +69,20 @@ class OCRemoteShareDataSource( return response.toModel() } + override fun addGraphShare( + accountName: String, + spaceId: String, + itemId: String, + member: OCMember, + roleId: String, + expirationDate: String? + ) { + val memberType = OCMemberType.toString(member.type).lowercase() + executeRemoteOperation { + clientManager.getShareService(accountName).addGraphShare(spaceId, itemId, member.id, memberType, roleId, expirationDate) + } + } + override fun insert( remoteFilePath: String, shareType: ShareType, @@ -137,6 +153,8 @@ class OCRemoteShareDataSource( members = membersResponse.filter { it.grantedToV2 != null }.map { spaceMemberResponse -> MemberPermission( id = spaceMemberResponse.id ?: "", + memberId = spaceMemberResponse.grantedToV2?.user?.id + ?: spaceMemberResponse.grantedToV2?.group?.id ?: "", expirationDateTime = spaceMemberResponse.expirationDateTime, displayName = spaceMemberResponse.grantedToV2?.user?.displayName ?: spaceMemberResponse.grantedToV2?.group?.displayName ?: "", diff --git a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepository.kt b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepository.kt index 0f33d4f3dab..f09f19a33c8 100644 --- a/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepository.kt +++ b/owncloudData/src/main/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepository.kt @@ -25,6 +25,7 @@ package com.owncloud.android.data.sharing.shares.repository import androidx.lifecycle.LiveData import com.owncloud.android.data.sharing.shares.datasources.LocalShareDataSource import com.owncloud.android.data.sharing.shares.datasources.RemoteShareDataSource +import com.owncloud.android.domain.members.model.OCMember import com.owncloud.android.domain.sharing.shares.ShareRepository import com.owncloud.android.domain.sharing.shares.model.OCShare import com.owncloud.android.domain.sharing.shares.model.ShareType @@ -123,6 +124,15 @@ class OCShareRepository( override fun getGraphShares(accountName: String, spaceId: String, itemId: String): OCPermissions = remoteShareDataSource.getGraphShares(accountName, spaceId, itemId) + override fun addGraphShare( + accountName: String, + spaceId: String, + itemId: String, + member: OCMember, + roleId: String, + expirationDate: String? + ) = remoteShareDataSource.addGraphShare(accountName, spaceId, itemId, member, roleId, expirationDate) + override fun refreshSharesFromNetwork( filePath: String, accountName: String diff --git a/owncloudData/src/main/java/com/owncloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt b/owncloudData/src/main/java/com/owncloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt index c5751a11c0e..2f1e880af20 100644 --- a/owncloudData/src/main/java/com/owncloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt +++ b/owncloudData/src/main/java/com/owncloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt @@ -209,6 +209,8 @@ class OCRemoteSpacesDataSource( members = membersResponse.filter { it.grantedToV2 != null }.map { spaceMemberResponse -> MemberPermission( id = spaceMemberResponse.id ?: "", + memberId = spaceMemberResponse.grantedToV2?.user?.id + ?: spaceMemberResponse.grantedToV2?.group?.id ?: "", expirationDateTime = spaceMemberResponse.expirationDateTime, displayName = spaceMemberResponse.grantedToV2?.user?.displayName ?: spaceMemberResponse.grantedToV2?.group?.displayName ?: "", diff --git a/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSourceTest.kt b/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSourceTest.kt index 52ee3ab4747..ac5f7b28dec 100644 --- a/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSourceTest.kt +++ b/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/datasources/implementation/OCRemoteShareDataSourceTest.kt @@ -28,6 +28,7 @@ import com.owncloud.android.data.sharing.shares.datasources.implementation.OCRem import com.owncloud.android.data.sharing.shares.datasources.mapper.RemoteShareMapper import com.owncloud.android.domain.exceptions.ShareForbiddenException import com.owncloud.android.domain.exceptions.ShareNotFoundException +import com.owncloud.android.domain.members.model.OCMemberType import com.owncloud.android.domain.sharing.shares.model.ShareType import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.resources.shares.ShareResponse @@ -36,6 +37,8 @@ import com.owncloud.android.testutil.OC_ACCOUNT_NAME import com.owncloud.android.testutil.OC_FILE import com.owncloud.android.testutil.OC_SHARE import com.owncloud.android.testutil.OC_SPACE_PROJECT_WITH_IMAGE +import com.owncloud.android.testutil.OC_USER_MEMBER +import com.owncloud.android.testutil.SPACE_MEMBERS import com.owncloud.android.testutil.SPACE_PERMISSIONS_RESPONSE import com.owncloud.android.utils.createRemoteOperationResultMock import io.mockk.every @@ -52,6 +55,8 @@ class OCRemoteShareDataSourceTest { private val remoteShareMapper = RemoteShareMapper() private val clientManager: ClientManager = mockk(relaxed = true) + private val userType = OCMemberType.toString(OC_USER_MEMBER.type).lowercase() + @Before fun setUp() { every { clientManager.getShareService(any()) } returns ocShareService @@ -408,6 +413,43 @@ class OCRemoteShareDataSourceTest { } } + @Test + fun `addGraphShare adds a graph share correctly`() { + val addGraphShareResult = createRemoteOperationResultMock(Unit, isSuccess = true) + + every { + ocShareService.addGraphShare( + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + memberId = OC_USER_MEMBER.id, + memberType = userType, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + } returns addGraphShareResult + + ocRemoteShareDataSource.addGraphShare( + accountName = OC_ACCOUNT_NAME, + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + member = OC_USER_MEMBER, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + + verify(exactly = 1) { + clientManager.getShareService(OC_ACCOUNT_NAME) + ocShareService.addGraphShare( + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + memberId = OC_USER_MEMBER.id, + memberType = userType, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + } + } + @Test(expected = ShareNotFoundException::class) fun `insert throws a ShareNotFoundException when share is not found`() { insertShareOperationWithError(RemoteOperationResult.ResultCode.SHARE_NOT_FOUND) diff --git a/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepositoryTest.kt b/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepositoryTest.kt index d5c999a9e65..12063015db5 100644 --- a/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepositoryTest.kt +++ b/owncloudData/src/test/java/com/owncloud/android/data/sharing/shares/repository/OCShareRepositoryTest.kt @@ -36,6 +36,7 @@ import com.owncloud.android.testutil.OC_PUBLIC_SHARE import com.owncloud.android.testutil.OC_SHARE import com.owncloud.android.testutil.OC_SHAREE import com.owncloud.android.testutil.OC_SPACE_PROJECT_WITH_IMAGE +import com.owncloud.android.testutil.OC_USER_MEMBER import com.owncloud.android.testutil.SPACE_MEMBERS import com.owncloud.android.testutil.livedata.getLastEmittedValue import io.mockk.every @@ -256,6 +257,40 @@ class OCShareRepositoryTest { } } + @Test + fun `addGraphShare adds a share correctly`() { + every { + remoteShareDataSource.addGraphShare( + accountName = OC_ACCOUNT_NAME, + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + member = OC_USER_MEMBER, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + } returns Unit + + ocShareRepository.addGraphShare( + accountName = OC_ACCOUNT_NAME, + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + member = OC_USER_MEMBER, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + + verify(exactly = 1) { + remoteShareDataSource.addGraphShare( + accountName = OC_ACCOUNT_NAME, + spaceId = OC_SPACE_PROJECT_WITH_IMAGE.id, + itemId = OC_FILE.remoteId.orEmpty(), + member = OC_USER_MEMBER, + roleId = SPACE_MEMBERS.roles[0].id, + expirationDate = null + ) + } + } + @Test fun `refreshSharesFromNetwork refreshes shares correctly when the list of shares received is not empty`() { every { diff --git a/owncloudDomain/src/main/java/com/owncloud/android/domain/exceptions/ConflictShareException.kt b/owncloudDomain/src/main/java/com/owncloud/android/domain/exceptions/ConflictShareException.kt new file mode 100644 index 00000000000..f86a8e51938 --- /dev/null +++ b/owncloudDomain/src/main/java/com/owncloud/android/domain/exceptions/ConflictShareException.kt @@ -0,0 +1,23 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.domain.exceptions + +class ConflictShareException : Exception() diff --git a/owncloudDomain/src/main/java/com/owncloud/android/domain/roles/model/OCRole.kt b/owncloudDomain/src/main/java/com/owncloud/android/domain/roles/model/OCRole.kt index 261daf79b43..4a2ca53a76d 100644 --- a/owncloudDomain/src/main/java/com/owncloud/android/domain/roles/model/OCRole.kt +++ b/owncloudDomain/src/main/java/com/owncloud/android/domain/roles/model/OCRole.kt @@ -31,18 +31,26 @@ data class OCRole( ): Parcelable enum class OCRoleType { - CAN_VIEW, CAN_EDIT, CAN_MANAGE, UNKNOWN_ROLE; + CAN_VIEW, CAN_VIEW_SHARE, CAN_EDIT, CAN_EDIT_SHARE, CAN_EDIT_FOLDER_SHARE, CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE, CAN_MANAGE, UNKNOWN_ROLE; companion object { private const val CAN_VIEW_ID = "a8d5fe5e-96e3-418d-825b-534dbdf22b99" + private const val CAN_VIEW_SHARE_ID = "b1e2218d-eef8-4d4c-b82d-0f1a1b48f3b5" private const val CAN_EDIT_ID = "58c63c02-1d89-4572-916a-870abc5a1b7d" + private const val CAN_EDIT_SHARE_ID = "2d00ce52-1fc2-4dbc-8b95-a73b73395f5a" + private const val CAN_EDIT_FOLDER_SHARE_ID = "1c996275-f1c9-4e71-abdf-a42f6495e960" + private const val CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE_ID = "fb6c3e19-e378-47e5-b277-9732f9de6e21" private const val CAN_MANAGE_ID = "312c0871-5ef7-4b3a-85b6-0e4074c64049" private const val UNKNOWN_ROLE_ID = "unknown-role" fun parseFromId(roleId: String): OCRoleType = when (roleId) { CAN_VIEW_ID -> CAN_VIEW + CAN_VIEW_SHARE_ID -> CAN_VIEW_SHARE CAN_EDIT_ID -> CAN_EDIT + CAN_EDIT_SHARE_ID -> CAN_EDIT_SHARE + CAN_EDIT_FOLDER_SHARE_ID -> CAN_EDIT_FOLDER_SHARE + CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE_ID -> CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE CAN_MANAGE_ID -> CAN_MANAGE else -> UNKNOWN_ROLE } @@ -50,7 +58,11 @@ enum class OCRoleType { fun toString(roleType: OCRoleType): String = when (roleType) { CAN_VIEW -> CAN_VIEW_ID + CAN_VIEW_SHARE -> CAN_VIEW_SHARE_ID CAN_EDIT -> CAN_EDIT_ID + CAN_EDIT_SHARE -> CAN_EDIT_SHARE_ID + CAN_EDIT_FOLDER_SHARE -> CAN_EDIT_FOLDER_SHARE_ID + CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE -> CAN_EDIT_WITH_TRASHBIN_FOLDER_SHARE_ID CAN_MANAGE -> CAN_MANAGE_ID UNKNOWN_ROLE -> UNKNOWN_ROLE_ID } diff --git a/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/ShareRepository.kt b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/ShareRepository.kt index 4f8f4acfae5..6f70df52236 100644 --- a/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/ShareRepository.kt +++ b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/ShareRepository.kt @@ -23,6 +23,7 @@ package com.owncloud.android.domain.sharing.shares import androidx.lifecycle.LiveData +import com.owncloud.android.domain.members.model.OCMember import com.owncloud.android.domain.sharing.shares.model.OCShare import com.owncloud.android.domain.sharing.shares.model.ShareType import com.owncloud.android.domain.sharing.shares.model.OCPermissions @@ -77,7 +78,20 @@ interface ShareRepository { fun getShareAsLiveData(remoteId: String): LiveData - fun getGraphShares(accountName: String, spaceId: String, itemId: String): OCPermissions + fun getGraphShares( + accountName: String, + spaceId: String, + itemId: String + ): OCPermissions + + fun addGraphShare( + accountName: String, + spaceId: String, + itemId: String, + member: OCMember, + roleId: String, + expirationDate: String? + ) fun refreshSharesFromNetwork(filePath: String, accountName: String) diff --git a/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/model/OCPermissions.kt b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/model/OCPermissions.kt index 55f43a5708b..a79b410ef63 100644 --- a/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/model/OCPermissions.kt +++ b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/model/OCPermissions.kt @@ -35,6 +35,7 @@ data class OCPermissions( @Parcelize data class MemberPermission( val id: String, + val memberId: String, val expirationDateTime: String?, val displayName: String, val roles: List, diff --git a/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/usecases/AddGraphShareAsyncUseCase.kt b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/usecases/AddGraphShareAsyncUseCase.kt new file mode 100644 index 00000000000..2cd59340779 --- /dev/null +++ b/owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/usecases/AddGraphShareAsyncUseCase.kt @@ -0,0 +1,42 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.domain.sharing.shares.usecases + +import com.owncloud.android.domain.BaseUseCaseWithResult +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.domain.sharing.shares.ShareRepository + +class AddGraphShareAsyncUseCase( + private val shareRepository: ShareRepository +) : BaseUseCaseWithResult() { + + override fun run(params: Params) = + shareRepository.addGraphShare(params.accountName, params.spaceId, params.itemId, params.member, params.roleId, params.expirationDate) + + data class Params( + val accountName: String, + val spaceId: String, + val itemId: String, + val member: OCMember, + val roleId: String, + val expirationDate: String? + ) +} diff --git a/owncloudTestUtil/src/main/java/com/owncloud/android/testutil/OCSpace.kt b/owncloudTestUtil/src/main/java/com/owncloud/android/testutil/OCSpace.kt index 935fe24aada..e180f636703 100644 --- a/owncloudTestUtil/src/main/java/com/owncloud/android/testutil/OCSpace.kt +++ b/owncloudTestUtil/src/main/java/com/owncloud/android/testutil/OCSpace.kt @@ -393,12 +393,14 @@ val SPACE_MEMBERS = OCPermissions( members = listOf( MemberPermission( id = "u:$OC_USER_ID", + memberId = OC_USER_ID, expirationDateTime = null, displayName = OC_USER_INFO.displayName, roles = listOf("312c0871-5ef7-4b3a-85b6-0e4074c64049") ), MemberPermission( id = "g:${OC_USER_GROUPS[0]}", + memberId = OC_USER_GROUPS[0], expirationDateTime = "2025-07-03T12:09:43.364Z", displayName = "group-1", roles = listOf("a8d5fe5e-96e3-418d-825b-534dbdf22b99")