Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8139f22
feat: show "+" icon for adding new members only for space managers
joragua Jan 14, 2026
04f10d5
refactor: move item header from fragment to members activity
joragua Jan 14, 2026
ed2d5d4
feat: create and display the new layout to add members
joragua Jan 15, 2026
699b901
feat: implement logic to search new users
joragua Jan 16, 2026
0278f0b
feat: implement logic to search new groups
joragua Jan 19, 2026
572b294
chore: add error logs for clarity
joragua Jan 19, 2026
556f015
feat: add display name sorting for space members
joragua Jan 20, 2026
2554b36
test: create tests for OCRemoteMembersDataSource and OCMembersRepository
joragua Jan 20, 2026
2232801
refactor: rename members strings for consistent naming
joragua Jan 20, 2026
8f508ac
refactor: remove blank lines and unnecessary "+" from view id reference
joragua Jan 21, 2026
aeb205f
refactor: rewrite members list filter and space members diff util for…
joragua Jan 21, 2026
4eb41e6
test: align test structure with production classes
joragua Jan 21, 2026
4bee490
refactor: use a non-deprecated transaction method for add member frag…
joragua Jan 21, 2026
45e3ac0
fix: refresh members list correctly when typing
joragua Jan 21, 2026
c777eff
fix: wrap query parameter in quotes to support special characters
joragua Jan 21, 2026
6bb5cd8
feat: show a message when there are no matches for users and groups
joragua Jan 22, 2026
b3b4c63
feat: display a spinner while members search is loading
joragua Jan 22, 2026
0730cad
fix: prevent multiple search requests when typing fast
joragua Jan 23, 2026
7e9d118
fix: hide empty state or members list while search is loading
joragua Jan 23, 2026
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
Expand Up @@ -4,7 +4,7 @@
* @author David González Verdugo
* @author Jorge Aguado Recio
*
* Copyright (C) 2025 ownCloud GmbH.
* 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,
Expand Down Expand Up @@ -33,6 +33,8 @@ import com.owncloud.android.data.capabilities.datasources.implementation.OCRemot
import com.owncloud.android.data.capabilities.datasources.mapper.RemoteCapabilityMapper
import com.owncloud.android.data.files.datasources.RemoteFileDataSource
import com.owncloud.android.data.files.datasources.implementation.OCRemoteFileDataSource
import com.owncloud.android.data.members.datasources.RemoteMembersDataSource
import com.owncloud.android.data.members.datasources.implementation.OCRemoteMembersDataSource
import com.owncloud.android.data.oauth.datasources.RemoteOAuthDataSource
import com.owncloud.android.data.oauth.datasources.implementation.OCRemoteOAuthDataSource
import com.owncloud.android.data.roles.datasources.RemoteRolesDataSource
Expand Down Expand Up @@ -76,6 +78,7 @@ val remoteDataSourceModule = module {
singleOf(::OCRemoteAuthenticationDataSource) bind RemoteAuthenticationDataSource::class
singleOf(::OCRemoteCapabilitiesDataSource) bind RemoteCapabilitiesDataSource::class
singleOf(::OCRemoteFileDataSource) bind RemoteFileDataSource::class
singleOf(::OCRemoteMembersDataSource) bind RemoteMembersDataSource::class
singleOf(::OCRemoteOAuthDataSource) bind RemoteOAuthDataSource::class
singleOf(::OCRemoteRolesDataSource) bind RemoteRolesDataSource::class
singleOf(::OCRemoteServerInfoDataSource) bind RemoteServerInfoDataSource::class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* @author Juan Carlos Garrote Gascón
* @author Jorge Aguado Recio
*
* Copyright (C) 2025 ownCloud GmbH.
* 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,
Expand All @@ -28,6 +28,7 @@ import com.owncloud.android.data.authentication.repository.OCAuthenticationRepos
import com.owncloud.android.data.capabilities.repository.OCCapabilityRepository
import com.owncloud.android.data.files.repository.OCFileRepository
import com.owncloud.android.data.folderbackup.repository.OCFolderBackupRepository
import com.owncloud.android.data.members.repository.OCMembersRepository
import com.owncloud.android.data.oauth.repository.OCOAuthRepository
import com.owncloud.android.data.roles.repository.OCRolesRepository
import com.owncloud.android.data.server.repository.OCServerInfoRepository
Expand All @@ -43,6 +44,7 @@ import com.owncloud.android.domain.authentication.oauth.OAuthRepository
import com.owncloud.android.domain.automaticuploads.FolderBackupRepository
import com.owncloud.android.domain.capabilities.CapabilityRepository
import com.owncloud.android.domain.files.FileRepository
import com.owncloud.android.domain.members.MembersRepository
import com.owncloud.android.domain.roles.RolesRepository
import com.owncloud.android.domain.server.ServerInfoRepository
import com.owncloud.android.domain.sharing.sharees.ShareeRepository
Expand All @@ -61,6 +63,7 @@ val repositoryModule = module {
factoryOf(::OCCapabilityRepository) bind CapabilityRepository::class
factoryOf(::OCFileRepository) bind FileRepository::class
factoryOf(::OCFolderBackupRepository) bind FolderBackupRepository::class
factoryOf(::OCMembersRepository) bind MembersRepository::class
factoryOf(::OCOAuthRepository) bind OAuthRepository::class
factoryOf(::OCRolesRepository) bind RolesRepository::class
factoryOf(::OCServerInfoRepository) bind ServerInfoRepository::class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @author Aitor Ballesteros Pavón
* @author Jorge Aguado Recio
*
* Copyright (C) 2025 ownCloud GmbH.
* 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,
Expand Down Expand Up @@ -107,6 +107,7 @@ import com.owncloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAc
import com.owncloud.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream
import com.owncloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
import com.owncloud.android.domain.spaces.usecases.GetSpaceMembersUseCase
import com.owncloud.android.domain.members.usecases.SearchMembersUseCase
import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransferByIdUseCase
import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransfersUseCase
import com.owncloud.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
Expand Down Expand Up @@ -306,4 +307,7 @@ val useCaseModule = module {

// Roles
factoryOf(::GetRolesAsyncUseCase)

// Members
factoryOf(::SearchMembersUseCase)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fun SpaceMenuOption.toStringResId() =
SpaceMenuOption.ENABLE -> R.string.enable_space
SpaceMenuOption.DELETE -> R.string.delete_space
SpaceMenuOption.SET_ICON -> R.string.set_space_icon
SpaceMenuOption.MEMBERS -> R.string.space_members
SpaceMenuOption.MEMBERS -> R.string.members_title
}

fun SpaceMenuOption.toDrawableResId() =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* 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.spaces.members

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.spaces.model.OCSpace
import com.owncloud.android.domain.spaces.model.SpaceMember
import com.owncloud.android.extensions.collectLatestLifecycleFlow
import com.owncloud.android.extensions.showErrorInSnackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber

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

private val spaceMembersViewModel: SpaceMembersViewModel by viewModel {
parametersOf(
requireArguments().getString(ARG_ACCOUNT_NAME),
requireArguments().getParcelable<OCSpace>(ARG_CURRENT_SPACE)
)
}

private lateinit var searchMembersAdapter: SearchMembersAdapter
private lateinit var recyclerView: RecyclerView

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = AddMemberFragmentBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchMembersAdapter = SearchMembersAdapter()
recyclerView = binding.membersRecyclerView
recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = searchMembersAdapter
}

val spaceMembers = requireArguments().getParcelableArrayList<SpaceMember>(ARG_SPACE_MEMBERS) ?: arrayListOf()

collectLatestLifecycleFlow(spaceMembersViewModel.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.filter { member ->
!spaceMembers.any { spaceMember ->
spaceMember.id == "u:${member.id}" || spaceMember.id == "g:${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)
}
}
}

binding.searchBar.apply {
requestFocus()
setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean = true

override fun onQueryTextChange(newText: String): Boolean {
if (newText.length > 2) { spaceMembersViewModel.searchMembers(newText) } else { spaceMembersViewModel.clearSearch() }
return true
}
})
}
}

private fun showOrHideEmptyView(hasMembers: Boolean) {
binding.membersRecyclerView.isVisible = hasMembers
binding.emptyDataParent.apply {
val shouldShow = !hasMembers && binding.searchBar.query.length > 2
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)
}
}
}

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
requireActivity().setTitle(R.string.members_add)
}

companion object {
private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME"
private const val ARG_CURRENT_SPACE = "CURRENT_SPACE"
private const val ARG_SPACE_MEMBERS = "SPACE_MEMBERS"

fun newInstance(
accountName: String,
currentSpace: OCSpace,
spaceMembers: List<SpaceMember>
): AddMemberFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_NAME, accountName)
putParcelable(ARG_CURRENT_SPACE, currentSpace)
putParcelableArrayList(ARG_SPACE_MEMBERS, ArrayList(spaceMembers))
}
return AddMemberFragment().apply {
arguments = args
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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.spaces.members

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.owncloud.android.R
import com.owncloud.android.databinding.MemberItemBinding
import com.owncloud.android.domain.members.model.OCMember
import com.owncloud.android.utils.PreferenceUtils

class SearchMembersAdapter: RecyclerView.Adapter<SearchMembersAdapter.SearchMembersViewHolder>() {

private var members = mutableListOf<OCMember>()

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchMembersViewHolder {
val inflater = LayoutInflater.from(parent.context)

val view = inflater.inflate(R.layout.member_item, parent, false)
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)

return SearchMembersViewHolder(view)
}

override fun onBindViewHolder(holder: SearchMembersViewHolder, position: Int) {
val member = members[position]

holder.binding.apply {
val isGroup = member.surname == GROUP_SURNAME
memberIcon.setImageResource(if (isGroup) R.drawable.ic_group else R.drawable.ic_user)
memberName.text = member.displayName
memberName.contentDescription = holder.itemView.context.getString(
if (isGroup) R.string.content_description_member_group else R.string.content_description_member_user, member.displayName
)
memberRole.text = if (isGroup) {
holder.itemView.context.getString(R.string.member_type_group)
} else {
if (member.surname == USER_SURNAME) holder.itemView.context.getString(R.string.member_type_user) else member.surname
}
}
}

override fun getItemCount(): Int = members.size

fun setMembers(members: List<OCMember>) {
val diffCallback = SpaceMembersDiffUtil(this.members, members)
val diffResult = DiffUtil.calculateDiff(diffCallback)
this.members.clear()
this.members.addAll(members)
diffResult.dispatchUpdatesTo(this)
}

class SearchMembersViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = MemberItemBinding.bind(itemView)
}

companion object {
private const val USER_SURNAME = "User"
private const val GROUP_SURNAME = "Group"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* @author Jorge Aguado Recio
*
* Copyright (C) 2025 ownCloud GmbH.
* 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,
Expand All @@ -25,21 +25,43 @@ import android.view.Menu
import android.view.MenuItem
import androidx.fragment.app.transaction
import com.owncloud.android.R
import com.owncloud.android.databinding.MembersActivityBinding
import com.owncloud.android.domain.spaces.model.OCSpace
import com.owncloud.android.domain.spaces.model.SpaceMember
import com.owncloud.android.ui.activity.FileActivity
import com.owncloud.android.utils.DisplayUtils

class SpaceMembersActivity: FileActivity() {
class SpaceMembersActivity: FileActivity(), SpaceMembersFragment.SpaceMemberFragmentListener {

private lateinit var binding: MembersActivityBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContentView(R.layout.members_activity)
binding = MembersActivityBinding.inflate(layoutInflater)
setContentView(binding.root)

setupStandardToolbar(title = null, displayHomeAsUpEnabled = true, homeButtonEnabled = true, displayShowTitleEnabled = true)

supportActionBar?.setHomeActionContentDescription(R.string.common_back)

val currentSpace = intent.getParcelableExtra<OCSpace>(EXTRA_SPACE)
val currentSpace = intent.getParcelableExtra<OCSpace>(EXTRA_SPACE) ?: return
binding.apply {
itemName.text = currentSpace.name
currentSpace.quota?.let { quota ->
val usedQuota = quota.used
val totalQuota = quota.total
itemSize.text = when {
usedQuota == null -> getString(R.string.drawer_unavailable_used_storage)
totalQuota == 0L -> DisplayUtils.bytesToHumanReadable(usedQuota, baseContext, true)
else -> getString(
R.string.drawer_quota,
DisplayUtils.bytesToHumanReadable(usedQuota, baseContext, true),
DisplayUtils.bytesToHumanReadable(totalQuota, baseContext, true),
quota.getRelative().toString())
}
}
}

supportFragmentManager.transaction {
if (savedInstanceState == null && currentSpace != null) {
Expand All @@ -59,8 +81,19 @@ class SpaceMembersActivity: FileActivity() {
super.onOptionsItemSelected(item)
}

override fun addMember(space: OCSpace, spaceMembers: List<SpaceMember>) {
val addMemberFragment = AddMemberFragment.newInstance(account.name, space, spaceMembers)
val transaction = supportFragmentManager.beginTransaction()
transaction.apply {
replace(R.id.members_fragment_container, addMemberFragment, TAG_ADD_MEMBER_FRAGMENT)
addToBackStack(null)
commit()
}
}

companion object {
private const val TAG_SPACE_MEMBERS_FRAGMENT = "SPACE_MEMBERS_FRAGMENT"
private const val TAG_ADD_MEMBER_FRAGMENT ="ADD_MEMBER_FRAGMENT"
const val EXTRA_SPACE = "EXTRA_SPACE"
}

Expand Down
Loading
Loading