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