Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<GraphShareViewModel> {
parametersOf(
requireArguments().getString(ARG_ACCOUNT_NAME),
requireArguments().getParcelable<OCFile>(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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,15 +35,15 @@ 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

class GraphShareFragment : Fragment() {
private var _binding: MembersFragmentBinding? = null
private val binding get() = _binding!!

private val graphShareViewModel by viewModel<GraphShareViewModel> {
private val graphShareViewModel by activityViewModel<GraphShareViewModel> {
parametersOf(
requireArguments().getString(ARG_ACCOUNT_NAME),
requireArguments().getParcelable<OCFile>(ARG_FILE)
Expand All @@ -52,6 +53,7 @@ class GraphShareFragment : Fragment() {
private lateinit var graphSharesAdapter: GraphSharesAdapter

private var roles: List<OCRole> = emptyList()
private var listener: GraphShareFragmentListener? = null

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = MembersFragmentBinding.inflate(inflater, container, false)
Expand All @@ -72,9 +74,28 @@ class GraphShareFragment : Fragment() {
graphShareViewModel.getGraphShares()
}

val file = requireArguments().getParcelable<OCFile>(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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,13 +63,32 @@ class GraphShareViewModel(
private val _shares = MutableStateFlow<Event<UIResult<OCPermissions>>?>(null)
val shares: StateFlow<Event<UIResult<OCPermissions>>?> = _shares

private val _userId = MutableStateFlow<Event<UIResult<String>>?>(null)
val userId: StateFlow<Event<UIResult<String>>?> = _userId

private val _members: MutableSharedFlow<MembersUIState> = MutableSharedFlow()
val members: SharedFlow<MembersUIState> = _members

private var searchJob: Job? = null
var capabilities: OCCapability? = null

init {
runUseCaseWithResult(
coroutineDispatcher = coroutineDispatcherProvider.io,
flow = _roles,
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() {
Expand All @@ -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<OCMember>,
val isLoading: Boolean,
val error: Throwable?
)
}
Loading