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..961a1a5d791 --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/sharing/AddGraphShareFragment.kt @@ -0,0 +1,170 @@ +/** + * 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 listOfMembersFiltered = uiState.members.filterNot { member -> + member.id == currentUserId || currentShares.any { it.memberId == member.id } + } + 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 792f7f50004..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) @@ -72,9 +74,28 @@ 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) { + 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 @@ -129,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..5929016ac1a 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,37 @@ 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.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 +63,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 +79,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 +111,27 @@ class GraphShareViewModel( ) ) } + + fun searchMembers(query: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch(coroutineDispatcherProvider.io) { + _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? + ) } 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")