From dd25bba960529f79c273e9544b706917de317dba Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 13:41:14 +0200 Subject: [PATCH 1/6] feat: enable + button when user has sharing permission Signed-off-by: Jorge Aguado Recio --- .../android/presentation/sharing/GraphShareFragment.kt | 3 +++ 1 file changed, 3 insertions(+) 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..e8900f7f30d 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 @@ -72,6 +72,9 @@ class GraphShareFragment : Fragment() { graphShareViewModel.getGraphShares() } + val file = requireArguments().getParcelable(ARG_FILE) + binding.addMemberButton.isVisible = file?.hasResharePermission ?: false + subscribeToViewModels() } From 75d1f49e0cccacdfd0d8da19d9d6f3e3492d78e6 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 13:41:54 +0200 Subject: [PATCH 2/6] feat: add member search screen for creating shares in an oCIS server Signed-off-by: Jorge Aguado Recio --- .../sharing/AddGraphShareFragment.kt | 171 ++++++++++++++++++ .../sharing/GraphShareFragment.kt | 26 ++- .../sharing/GraphShareViewModel.kt | 62 +++++++ .../presentation/sharing/ShareActivity.kt | 19 +- .../implementation/OCRemoteShareDataSource.kt | 2 + .../OCRemoteSpacesDataSource.kt | 2 + .../sharing/shares/model/OCPermissions.kt | 1 + .../com/owncloud/android/testutil/OCSpace.kt | 2 + 8 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt 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..892fc8390da --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt @@ -0,0 +1,171 @@ +/** + * 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.core.view.isVisible +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.extensions.collectLatestLifecycleFlow +import com.owncloud.android.extensions.showErrorInSnackbar +import com.owncloud.android.presentation.spaces.members.SearchMembersAdapter +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 recyclerView: RecyclerView + + 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() + + 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) { + + } + + private fun showOrHideEmptyView(hasMembers: Boolean) { + binding.membersRecyclerView.isVisible = hasMembers + binding.emptyDataParent.apply { + val shouldShow = !hasMembers && binding.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) + } + } + } + + private fun subscribeToViewModels() { + val currentShares = (graphShareViewModel.shares.value?.peekContent() as? UIResult.Success)?.data?.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() + showOrHideEmptyView(hasMembers) + 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) + } + } + } + } + + 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 e8900f7f30d..bea4ee810b8 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 @@ -34,7 +35,7 @@ import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.extensions.showErrorInSnackbar 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 +43,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 +53,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) @@ -73,11 +75,27 @@ class GraphShareFragment : Fragment() { } 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) { + 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 @@ -132,6 +150,10 @@ class GraphShareFragment : Fragment() { } } + 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..26c5667d9d9 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,38 @@ 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.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 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 +64,15 @@ 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 var searchJob: Job? = null + var capabilities: OCCapability? = null + init { runUseCaseWithResult( coroutineDispatcher = coroutineDispatcherProvider.io, @@ -56,6 +80,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 +112,32 @@ class GraphShareViewModel( ) ) } + + 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)) + } + } + + data class MembersUIState( + val members: List, + val isLoading: Boolean, + val error: Throwable? + ) + + 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/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..5eab9a84012 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 @@ -137,6 +137,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/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/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/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") From 46e6ca560e9cec2331bf846783339a5f7f75185c Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 13:43:29 +0200 Subject: [PATCH 3/6] feat: implement methods and network operation to create a share in an oCIS server Signed-off-by: Jorge Aguado Recio --- .../dependecyinjection/UseCaseModule.kt | 2 + .../android/extensions/ThrowableExt.kt | 2 + .../sharing/AddGraphShareFragment.kt | 140 +++++++++++++++++- .../sharing/GraphShareFragment.kt | 18 +++ .../sharing/GraphShareViewModel.kt | 54 +++++++ .../spaces/members/SpaceRolesAdapter.kt | 4 + .../src/main/res/drawable/ic_uploads.xml | 3 +- .../main/res/layout/add_member_fragment.xml | 2 +- .../res/layout/expiration_date_layout.xml | 4 +- owncloudApp/src/main/res/values/strings.xml | 4 + .../operations/RemoteOperationResult.java | 1 + .../shares/AddRemoteGraphShareOperation.kt | 105 +++++++++++++ .../resources/shares/services/ShareService.kt | 9 ++ .../services/implementation/OCShareService.kt | 11 ++ .../android/data/RemoteOperationHandler.kt | 2 + .../datasources/RemoteShareDataSource.kt | 10 ++ .../implementation/OCRemoteShareDataSource.kt | 16 ++ .../shares/repository/OCShareRepository.kt | 10 ++ .../exceptions/ConflictShareException.kt | 23 +++ .../android/domain/roles/model/OCRole.kt | 14 +- .../domain/sharing/shares/ShareRepository.kt | 16 +- .../usecases/AddGraphShareAsyncUseCase.kt | 42 ++++++ 22 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 owncloudComLibrary/src/main/java/com/owncloud/android/lib/resources/shares/AddRemoteGraphShareOperation.kt create mode 100644 owncloudDomain/src/main/java/com/owncloud/android/domain/exceptions/ConflictShareException.kt create mode 100644 owncloudDomain/src/main/java/com/owncloud/android/domain/sharing/shares/usecases/AddGraphShareAsyncUseCase.kt 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/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 index 892fc8390da..82ee2a8c2f0 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt @@ -20,6 +20,8 @@ package com.owncloud.android.presentation.sharing +import android.app.DatePickerDialog +import android.icu.util.Calendar import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -34,12 +36,19 @@ 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.members.model.OCMemberType +import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.extensions.showErrorInSnackbar 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 +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdapterListener { private var _binding: AddMemberFragmentBinding? = null @@ -53,7 +62,9 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap } 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 @@ -79,6 +90,16 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap 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 { @@ -97,7 +118,7 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap } override fun onMemberClick(member: OCMember) { - + graphShareViewModel.onMemberSelected(member) } private fun showOrHideEmptyView(hasMembers: Boolean) { @@ -114,7 +135,9 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap } private fun subscribeToViewModels() { - val currentShares = (graphShareViewModel.shares.value?.peekContent() as? UIResult.Success)?.data?.members ?: emptyList() + 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 -> @@ -151,6 +174,119 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap } } } + + 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 -> + bindSelectedMember(member) + } + it.selectedExpirationDate?.let { expirationDate -> + binding.expirationDateLayout.expirationDateValue.apply { + visibility = View.VISIBLE + text = DisplayUtils.displayDateToHumanReadable(expirationDate) + } + } + bindRoles(uiState.selectedRole?.id) + bindDatePickerDialog(uiState.selectedExpirationDate) + + binding.expirationDateLayout.apply { + expirationDateLayout.setOnClickListener { + if (uiState.selectedExpirationDate != null) { + openDatePickerDialog(uiState.selectedExpirationDate) + } 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) + } + } + } + } + + private fun bindSelectedMember(member: OCMember) { + binding.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 + } + } + + private fun bindRoles(selectedRoleId: String?) { + selectedRoleId?.let { + binding.inviteMemberButton.isEnabled = true + rolesAdapter.setSelectedRole(it) + } + } + + private fun bindDatePickerDialog(expirationDate: String?) { + binding.expirationDateLayout.expirationDateSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + openDatePickerDialog(expirationDate) + } else { + binding.expirationDateLayout.expirationDateValue.visibility = View.GONE + graphShareViewModel.onExpirationDateSelected(null) + } + } + } + + private fun openDatePickerDialog(expirationDate: String?) { + 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) + graphShareViewModel.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 + } + } + } } companion object { 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 bea4ee810b8..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 @@ -34,6 +34,7 @@ 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.activityViewModel import org.koin.core.parameter.parametersOf @@ -79,6 +80,7 @@ class GraphShareFragment : Fragment() { binding.addMemberButton.isVisible = file?.hasResharePermission ?: false binding.addMemberButton.setOnClickListener { if (file != null && accountName != null) { + graphShareViewModel.resetViewModel() listener?.addGraphShare(file = file, accountName = accountName) } } @@ -104,6 +106,7 @@ class GraphShareFragment : Fragment() { private fun subscribeToViewModels() { observeRoles() observeShares() + observeAddShareResult() } private fun observeRoles() { @@ -150,6 +153,21 @@ 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) } 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 26c5667d9d9..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 @@ -31,6 +31,7 @@ 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 @@ -48,6 +49,7 @@ 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, @@ -70,6 +72,12 @@ class GraphShareViewModel( 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 @@ -113,6 +121,29 @@ 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) { @@ -131,12 +162,35 @@ class GraphShareViewModel( } } + 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/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 5eab9a84012..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, 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/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/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? + ) +} From bd432a1f2c6bd024c3eee8d4d5dd7f561a6f9ef3 Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 13:45:03 +0200 Subject: [PATCH 4/6] test: create new tests for OCShareRepositoryTest and OCRemoteShareDataSourceTest Signed-off-by: Jorge Aguado Recio --- .../OCRemoteShareDataSourceTest.kt | 42 +++++++++++++++++++ .../repository/OCShareRepositoryTest.kt | 35 ++++++++++++++++ 2 files changed, 77 insertions(+) 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 { From dc199d33dcbf5350b57203b5cb264ab74743735a Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 13:45:43 +0200 Subject: [PATCH 5/6] chore: add calens file Signed-off-by: Jorge Aguado Recio --- changelog/unreleased/4952 | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/4952 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 From 3b3534cf248c390debf220baf1558e0b556e0acf Mon Sep 17 00:00:00 2001 From: Jorge Aguado Recio Date: Thu, 27 Aug 2026 14:09:52 +0200 Subject: [PATCH 6/6] refactor: extract shared fragment logic to extensions to avoid duplicated code Signed-off-by: Jorge Aguado Recio --- .../extensions/AddMemberFragmentBindingExt.kt | 56 +++++++++++ .../android/extensions/FragmentExt.kt | 62 ++++++++++++ .../sharing/AddGraphShareFragment.kt | 97 ++----------------- 3 files changed, 128 insertions(+), 87 deletions(-) create mode 100644 owncloudApp/src/main/java/com/owncloud/android/extensions/AddMemberFragmentBindingExt.kt 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/presentation/sharing/AddGraphShareFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt index 82ee2a8c2f0..0b9b9620b8a 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt @@ -20,14 +20,11 @@ package com.owncloud.android.presentation.sharing -import android.app.DatePickerDialog -import android.icu.util.Calendar import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -36,19 +33,20 @@ 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.members.model.OCMemberType 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 -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdapterListener { private var _binding: AddMemberFragmentBinding? = null @@ -121,19 +119,6 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap graphShareViewModel.onMemberSelected(member) } - private fun showOrHideEmptyView(hasMembers: Boolean) { - binding.membersRecyclerView.isVisible = hasMembers - binding.emptyDataParent.apply { - val shouldShow = !hasMembers && binding.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) - } - } - } - private fun subscribeToViewModels() { val currentPermissions = (graphShareViewModel.shares.value?.peekContent() as? UIResult.Success)?.data roles = currentPermissions?.roles ?: emptyList() @@ -166,7 +151,7 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap member.id == currentUserId || member.id in sharedMemberIds } val hasMembers = listOfMembersFiltered.isNotEmpty() - showOrHideEmptyView(hasMembers) + binding.showOrHideEmptyView(hasMembers, searchMinLength) if (hasMembers) searchMembersAdapter.setMembers(listOfMembersFiltered) uiState.error?.let { Timber.e(uiState.error, "Failed to retrieve available users and groups") @@ -185,7 +170,7 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap inviteMemberButton.contentDescription = getString(R.string.content_description_create_share_button) } it.selectedMember?.let { member -> - bindSelectedMember(member) + binding.bindSelectedMember(member) } it.selectedExpirationDate?.let { expirationDate -> binding.expirationDateLayout.expirationDateValue.apply { @@ -193,13 +178,13 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap text = DisplayUtils.displayDateToHumanReadable(expirationDate) } } - bindRoles(uiState.selectedRole?.id) - bindDatePickerDialog(uiState.selectedExpirationDate) + binding.bindRoles(rolesAdapter, uiState.selectedRole?.id) + bindDatePickerDialog(binding, uiState.selectedExpirationDate, graphShareViewModel::onExpirationDateSelected) binding.expirationDateLayout.apply { expirationDateLayout.setOnClickListener { if (uiState.selectedExpirationDate != null) { - openDatePickerDialog(uiState.selectedExpirationDate) + openDatePickerDialog(binding, uiState.selectedExpirationDate, graphShareViewModel::onExpirationDateSelected) } else { expirationDateSwitch.isChecked = true } @@ -227,68 +212,6 @@ class AddGraphShareFragment : Fragment(), SearchMembersAdapter.SearchMembersAdap } } - private fun bindSelectedMember(member: OCMember) { - binding.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 - } - } - - private fun bindRoles(selectedRoleId: String?) { - selectedRoleId?.let { - binding.inviteMemberButton.isEnabled = true - rolesAdapter.setSelectedRole(it) - } - } - - private fun bindDatePickerDialog(expirationDate: String?) { - binding.expirationDateLayout.expirationDateSwitch.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - openDatePickerDialog(expirationDate) - } else { - binding.expirationDateLayout.expirationDateValue.visibility = View.GONE - graphShareViewModel.onExpirationDateSelected(null) - } - } - } - - private fun openDatePickerDialog(expirationDate: String?) { - 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) - graphShareViewModel.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 - } - } - } - } - companion object { private const val ARG_FILE = "FILE" private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME"