Skip to content
Open
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
7 changes: 7 additions & 0 deletions changelog/unreleased/4945
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Enhancement: List shares for oCIS accounts

A new UI has been implemented to list and show information about the
shares of a file or folder on oCIS accounts (using Graph API)

https://github.com/owncloud/android/issues/4876
https://github.com/owncloud/android/pull/4945
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import com.owncloud.android.domain.sharing.shares.usecases.CreatePublicShareAsyn
import com.owncloud.android.domain.sharing.shares.usecases.DeleteShareAsyncUseCase
import com.owncloud.android.domain.sharing.shares.usecases.EditPrivateShareAsyncUseCase
import com.owncloud.android.domain.sharing.shares.usecases.EditPublicShareAsyncUseCase
import com.owncloud.android.domain.sharing.shares.usecases.GetGraphSharesAsyncUseCase
import com.owncloud.android.domain.sharing.shares.usecases.GetShareAsLiveDataUseCase
import com.owncloud.android.domain.sharing.shares.usecases.GetSharesAsLiveDataUseCase
import com.owncloud.android.domain.sharing.shares.usecases.RefreshSharesFromServerAsyncUseCase
Expand Down Expand Up @@ -233,6 +234,7 @@ val useCaseModule = module {
factoryOf(::DeleteShareAsyncUseCase)
factoryOf(::EditPrivateShareAsyncUseCase)
factoryOf(::EditPublicShareAsyncUseCase)
factoryOf(::GetGraphSharesAsyncUseCase)
factoryOf(::GetShareAsLiveDataUseCase)
factoryOf(::GetShareesAsyncUseCase)
factoryOf(::GetSharesAsLiveDataUseCase)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.owncloud.android.presentation.settings.automaticuploads.SettingsVideo
import com.owncloud.android.presentation.settings.logging.SettingsLogsViewModel
import com.owncloud.android.presentation.settings.more.SettingsMoreViewModel
import com.owncloud.android.presentation.settings.security.SettingsSecurityViewModel
import com.owncloud.android.presentation.sharing.GraphShareViewModel
import com.owncloud.android.presentation.sharing.ShareViewModel
import com.owncloud.android.presentation.spaces.SpacesListViewModel
import com.owncloud.android.presentation.spaces.links.SpaceLinksViewModel
Expand All @@ -70,6 +71,7 @@ val viewModelModule = module {
viewModelOf(::DrawerViewModel)
viewModelOf(::FileDetailsViewModel)
viewModelOf(::FileOperationsViewModel)
viewModelOf(::GraphShareViewModel)
viewModelOf(::LogListViewModel)
viewModelOf(::OAuthViewModel)
viewModelOf(::PatternViewModel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ package com.owncloud.android.extensions

import com.owncloud.android.domain.members.model.OCMember
import com.owncloud.android.domain.members.model.OCMemberType
import com.owncloud.android.domain.spaces.model.SpaceMember
import com.owncloud.android.domain.sharing.shares.model.MemberPermission

private const val GROUP_PREFIX = "g:"
private const val USER_PREFIX = "u:"

fun SpaceMember.toOCMember(): OCMember {
fun MemberPermission.toOCMember(): OCMember {
val isGroup = id.startsWith(GROUP_PREFIX)
val type = if (isGroup) OCMemberType.GROUP else OCMemberType.USER
return OCMember(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class ReleaseNotesViewModel(

companion object {
val releaseNotesList = listOf(
ReleaseNote(
title = R.string.release_notes_4_9_0_sharing_ng_title,
subtitle = R.string.release_notes_4_9_0_sharing_ng_subtitle,
type = ReleaseNoteType.ENHANCEMENT
),
ReleaseNote(
title = R.string.release_notes_bugfixes_title,
subtitle = R.string.release_notes_bugfixes_subtitle,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* 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.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.owncloud.android.R
import com.owncloud.android.databinding.MembersFragmentBinding
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.presentation.common.UIResult
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber
import kotlin.apply

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

private val graphShareViewModel by viewModel<GraphShareViewModel> {
parametersOf(
requireArguments().getString(ARG_ACCOUNT_NAME),
requireArguments().getParcelable<OCFile>(ARG_FILE)
)
}

private lateinit var graphSharesAdapter: GraphSharesAdapter

private var roles: List<OCRole> = emptyList()

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

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.membersTitle.text = getString(R.string.share_with_people_title)

graphSharesAdapter = GraphSharesAdapter()
binding.membersRecyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = graphSharesAdapter
}

binding.swipeRefreshMembers.setOnRefreshListener {
graphShareViewModel.getGraphShares()
}

subscribeToViewModels()
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}

private fun subscribeToViewModels() {
observeRoles()
observeShares()
}

private fun observeRoles() {
collectLatestLifecycleFlow(graphShareViewModel.roles) { event ->
event?.let {
when (val uiResult = event.peekContent()) {
is UIResult.Success -> {
uiResult.data?.let {
roles = it
graphShareViewModel.getGraphShares()
}
}
is UIResult.Loading -> { }
is UIResult.Error -> {
showErrorInSnackbar(R.string.share_sync_failed, uiResult.error)
Timber.e(uiResult.error, "Failed to retrieve platform roles")
}
}
}
}
}

private fun observeShares() {
collectLatestLifecycleFlow(graphShareViewModel.shares) { event ->
event?.let {
when (val uiResult = event.peekContent()) {
is UIResult.Success -> {
uiResult.data?.let {
val hasMembers = it.members.isNotEmpty()
binding.membersRecyclerView.isVisible = hasMembers
binding.noSharesMessage.isVisible = !hasMembers
if (hasMembers) graphSharesAdapter.setShares(it.members, it.roles)
binding.swipeRefreshMembers.isRefreshing = false
}
}
is UIResult.Loading -> { binding.swipeRefreshMembers.isRefreshing = true }
is UIResult.Error -> {
binding.swipeRefreshMembers.isRefreshing = false
showErrorInSnackbar(R.string.share_sync_failed, uiResult.error)
Timber.e(uiResult.error, "Failed to retrieve shares")
}
}
}
}
}

companion object {
private const val ARG_FILE = "FILE"
private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME"

fun newInstance(file: OCFile, accountName: String): GraphShareFragment {
val args = Bundle().apply {
putParcelable(ARG_FILE, file)
putString(ARG_ACCOUNT_NAME, accountName)
}
return GraphShareFragment().apply {
arguments = args
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* 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 androidx.lifecycle.ViewModel
import com.owncloud.android.domain.files.model.OCFile
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.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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class GraphShareViewModel(
private val getRolesAsyncUseCase: GetRolesAsyncUseCase,
private val getGraphSharesAsyncUseCase: GetGraphSharesAsyncUseCase,
private val accountName: String,
private val file: OCFile,
private val coroutineDispatcherProvider: CoroutinesDispatcherProvider,
) : ViewModel() {

private val _roles = MutableStateFlow<Event<UIResult<List<OCRole>>>?>(null)
val roles: StateFlow<Event<UIResult<List<OCRole>>>?> = _roles

private val _shares = MutableStateFlow<Event<UIResult<OCPermissions>>?>(null)
val shares: StateFlow<Event<UIResult<OCPermissions>>?> = _shares

init {
runUseCaseWithResult(
coroutineDispatcher = coroutineDispatcherProvider.io,
flow = _roles,
useCase = getRolesAsyncUseCase,
useCaseParams = GetRolesAsyncUseCase.Params(accountName = accountName),
)
}

fun getGraphShares() = runUseCaseWithResult(
coroutineDispatcher = coroutineDispatcherProvider.io,
showLoading = true,
flow = _shares,
useCase = getGraphSharesAsyncUseCase,
useCaseParams = GetGraphSharesAsyncUseCase.Params(
accountName = accountName,
spaceId = file.spaceId.orEmpty(),
itemId = file.remoteId.orEmpty()
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* 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.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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.roles.model.OCRole
import com.owncloud.android.domain.sharing.shares.model.MemberPermission
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.PreferenceUtils

class GraphSharesAdapter : RecyclerView.Adapter<GraphSharesAdapter.GraphShareViewHolder>() {

private var shares: List<MemberPermission> = emptyList()
private var rolesMap: Map<String, String> = emptyMap()

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GraphShareViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.member_item, parent, false)
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)
return GraphShareViewHolder(view)
}

override fun onBindViewHolder(holder: GraphShareViewHolder, position: Int) {
val share = shares[position]
val roleNames = share.roles.mapNotNull { rolesMap[it] }

holder.binding.apply {
memberIcon.setImageResource(if (share.isGroup) R.drawable.ic_group else R.drawable.ic_user)
memberName.text = share.displayName
memberName.contentDescription = holder.itemView.context.getString(
if (share.isGroup) R.string.content_description_member_group else R.string.content_description_member_user,
share.displayName
)
memberRole.text = roleNames.joinToString(", ")

val hasExpirationDate = share.expirationDateTime != null
expirationCalendarIcon.isVisible = hasExpirationDate
expirationDate.isVisible = hasExpirationDate
if (hasExpirationDate) {
expirationDate.text = DisplayUtils.displayDateToHumanReadable(share.expirationDateTime)
expirationDate.contentDescription =
holder.itemView.context.getString(R.string.content_description_member_expiration_date, expirationDate.text)
}
}
}

override fun getItemCount(): Int = shares.size

fun setShares(shares: List<MemberPermission>, roles: List<OCRole>) {
this.rolesMap = roles.associate { it.id to it.displayName }
val sortedShares = shares.sortedWith(
compareByDescending<MemberPermission> { share -> roles.indexOfFirst { it.id in share.roles } }
.thenBy { it.displayName }
)
val diffResult = DiffUtil.calculateDiff(GraphSharesDiffUtil(this.shares, sortedShares))
this.shares = sortedShares
diffResult.dispatchUpdatesTo(this)
}

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