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")