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
93 changes: 93 additions & 0 deletions app/src/main/java/com/nextcloud/utils/share/UnifiedShareSharees.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.utils.share

import com.nextcloud.android.common.ui.network.auth.ServerCredentials
import com.nextcloud.android.common.ui.share.avatar.ShareAvatarRepository
import com.nextcloud.android.common.ui.share.model.api.share.Share
import com.nextcloud.client.account.User
import com.nextcloud.utils.extensions.supportsUnifiedShare
import com.nextcloud.utils.extensions.toServerCredentials
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.shares.ShareType
import com.owncloud.android.lib.resources.shares.ShareeUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap

/**
* Replaces the sharees PROPFIND reported with the ones the unified share API reports, at the point where the files
* are written, so that readers take them straight from the [OCFile].
*
* This is the preferred approach only while the unified share system does not expose the legacy sharee data through
* PROPFIND. Once it is backward compatible and ships the sharees with the file listing again, the listing alone
* carries everything the UI needs and these extra requests can go away.
*/
object UnifiedShareSharees {
private const val MAX_CONCURRENT_REQUESTS = 8

private val unifiedShareSupport = ConcurrentHashMap<String, Boolean>()

suspend fun fill(user: User, files: List<OCFile>) {
if (files.isEmpty()) {
return
}

withContext(Dispatchers.IO) {
val credentials = user.toServerCredentials() ?: return@withContext
if (!supportsUnifiedShare(user.accountName, credentials)) {
return@withContext
}

val repository = ShareAvatarRepository(credentials)
val requestLimit = Semaphore(MAX_CONCURRENT_REQUESTS)

files
.map { file ->
async {
requestLimit.withPermit {
runCatching { repository.fetchSharees(file) }
}
}
}
.awaitAll()
}
}

@JvmStatic
fun fillBlocking(user: User, files: List<OCFile>) {
runCatching {
runBlocking { fill(user, files) }
}
}

private suspend fun supportsUnifiedShare(accountName: String, credentials: ServerCredentials): Boolean {
unifiedShareSupport[accountName]?.let { return it }

// a failed capability request stays uncached so that the next listing can resolve it again
val supported = runCatching { credentials.supportsUnifiedShare() }.getOrNull() ?: return false
unifiedShareSupport[accountName] = supported

return supported
}

private suspend fun ShareAvatarRepository.fetchSharees(file: OCFile) {
file.sharees = fetchShareAvatars(file.localId.toString())?.toAvatarSharees().orEmpty()
}

private fun List<Share>.toAvatarSharees(): List<ShareeUser> = asSequence()
.flatMap { share -> share.invitedRecipients }
.distinctBy { recipient -> recipient.value }
.map { recipient -> ShareeUser(recipient.value, recipient.displayName, ShareType.USER) }
.toList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.nextcloud.common.NextcloudClient;
import com.nextcloud.utils.ResultParser;
import com.nextcloud.utils.e2ee.E2EVersionHelper;
import com.nextcloud.utils.share.UnifiedShareSharees;
import com.nextcloud.utils.extensions.StringExtensionsKt;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl;
Expand Down Expand Up @@ -629,6 +630,8 @@ private void synchronizeData(List<Object> folderAndFiles) {
updateFileNameForEncryptedFile(fileDataStorageManager, metadata, mLocalFolder);
}

UnifiedShareSharees.fillBlocking(user, updatedFiles);

fileDataStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values());

mChildren = updatedFiles;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import android.text.TextUtils;

import com.nextcloud.client.account.User;
import com.nextcloud.utils.share.UnifiedShareSharees;
import com.nextcloud.client.jobs.download.FileDownloadHelper;
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerNotificationManager;
import com.nextcloud.utils.extensions.ExtensionsKt;
Expand Down Expand Up @@ -349,6 +350,8 @@ private void synchronizeData(List<Object> folderAndFiles) throws OperationCancel
}

// save updated contents in local database
UnifiedShareSharees.fillBlocking(user, updatedFiles);

storageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values());
mLocalFolder.setLastSyncDateForData(System.currentTimeMillis());
storageManager.saveFile(mLocalFolder);
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,42 @@ class AvatarGroupLayout @JvmOverloads constructor(
@Px
private val overlapPx: Int = DisplayUtils.convertDpToPixel(24f, context)

var boundFileId: Long? = null
set(value) {
if (field != value) {
displayedSharees = null
}
field = value
}

private var displayedSharees: List<ShareeUser>? = null

init {
checkNotNull(borderDrawable)
DrawableCompat.setTint(borderDrawable, ContextCompat.getColor(context, R.color.bg_default))
}

@Suppress("LongMethod", "TooGenericExceptionCaught")
fun setAvatars(user: User, sharees: MutableList<ShareeUser>, viewThemeUtils: ViewThemeUtils) {
fun setAvatars(user: User, sharees: List<ShareeUser>, viewThemeUtils: ViewThemeUtils) {
if (sharees == displayedSharees) {
return
}
displayedSharees = sharees

val context = getContext()
removeAllViews()

if (sharees.isEmpty()) {
visibility = GONE
return
}
visibility = VISIBLE

var avatarLayoutParams: LayoutParams?
val shareeSize = min(sharees.size, MAX_AVATAR_COUNT)
val resources = context.resources
val avatarRadius = resources.getDimension(R.dimen.list_item_avatar_icon_radius)
val serverName = user.accountName.substringAfterLast('@')
var sharee: ShareeUser

var avatarCount = 0
Expand Down Expand Up @@ -102,7 +125,7 @@ class AvatarGroupLayout @JvmOverloads constructor(
)

else -> {
avatar.tag = sharee
avatar.tag = "${sharee.userId}@$serverName"
DisplayUtils.setAvatar(
user,
sharee.userId!!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import com.owncloud.android.ui.activity.ComponentsGetter;
import com.owncloud.android.ui.activity.DrawerActivity;
import com.owncloud.android.ui.activity.FileDisplayActivity;
import com.owncloud.android.ui.adapter.helper.AvatarShareesProvider;
import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider;
import com.owncloud.android.ui.adapter.helper.OCFileListAdapterHelper;
import com.owncloud.android.ui.fragment.OCFileListFragment;
Expand Down Expand Up @@ -135,6 +136,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
private final List<OCFile> recommendedFiles = new ArrayList<>();
private RecommendedFilesAdapter recommendedFilesAdapter;
private final OCFileListAdapterHelper helper = new OCFileListAdapterHelper();
private final AvatarShareesProvider avatarShareesProvider = new AvatarShareesProvider();
private final ThumbnailGenerator thumbnailGenerator;

public OCFileListAdapter(
Expand Down Expand Up @@ -601,23 +603,19 @@ private void bindSharedAvatars(ListItemViewHolder holder, OCFile file) {
final var sharedAvatars = holder.getSharedAvatars();

if (!(file.isSharedWithMe() || file.isSharedWithSharee()) || isMultiSelect() || gridView || hideItemOptions) {
sharedAvatars.setBoundFileId(null);
sharedAvatars.setVisibility(View.GONE);
if (sharedAvatars.getChildCount() > 0) {
sharedAvatars.removeAllViews();
}
return;
}

sharedAvatars.setVisibility(View.VISIBLE);
if (sharedAvatars.getChildCount() > 0) {
sharedAvatars.removeAllViews();
}
final long fileId = file.getFileId();
sharedAvatars.setBoundFileId(fileId);
sharedAvatars.setOnClickListener(view -> ocFileListFragmentInterface.onShareIconClick(file));

helper.getAvatarSharees(file, user, userId, avatars -> {
sharedAvatars.setAvatars(user, avatars, viewThemeUtils);
sharedAvatars.setOnClickListener(view -> ocFileListFragmentInterface.onShareIconClick(file));
return Unit.INSTANCE;
});
sharedAvatars.setAvatars(user, avatarShareesProvider.get(file, userId), viewThemeUtils);
}

private void bindListItemViewHolder(ListItemViewHolder holder, OCFile file) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.ui.adapter.helper

import android.content.Context
import android.content.res.Resources
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.lib.resources.shares.ShareType
import com.owncloud.android.lib.resources.shares.ShareeUser
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class AvatarShareesProvider {

fun get(file: OCFile, userId: String?): List<ShareeUser> {
val sharees = file.sharees

val ownerSharee = file.ownerId
?.takeIf { it.isNotEmpty() && it != userId }
?.let { ShareeUser(it, file.ownerDisplayName, ShareType.USER) }
?.takeIf { it !in sharees }

return listOfNotNull(ownerSharee) + sharees.asReversed()
}

companion object {
private val cachedAvatarScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

@JvmStatic
@Suppress("LongParameterList")
fun showCachedAvatar(
userId: String,
serverName: String,
listener: AvatarGenerationListener,
resources: Resources,
callContext: Any,
context: Context
) {
val accountName = "$userId@$serverName"

cachedAvatarScope.launch {
val eTag = ArbitraryDataProviderImpl(context)
.getValue(accountName, ThumbnailsCacheManager.AVATAR)
val cachedBitmap = ThumbnailsCacheManager
.getBitmapFromDiskCache("a_${userId}_${serverName}_$eTag") ?: return@launch
val avatar = BitmapUtils.bitmapToCircularBitmapDrawable(resources, cachedBitmap)

withContext(Dispatchers.Main) {
if (listener.shouldCallGeneratedCallback(accountName, callContext)) {
listener.avatarGenerated(avatar, callContext)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@

package com.owncloud.android.ui.adapter.helper

import com.nextcloud.android.common.ui.share.avatar.ShareAvatarRepository
import com.nextcloud.android.common.ui.share.model.api.share.Share
import com.nextcloud.client.account.User
import com.nextcloud.client.database.entity.FileEntity
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.extensions.filterFilenames
import com.nextcloud.utils.extensions.isTempFile
import com.nextcloud.utils.extensions.supportsUnifiedShare
import com.nextcloud.utils.extensions.toServerCredentials
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.shares.ShareType
import com.owncloud.android.lib.resources.shares.ShareeUser
import com.owncloud.android.utils.FileSortOrder
import com.owncloud.android.utils.MimeTypeUtil
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -61,38 +54,6 @@ class OCFileListAdapterHelper {
}
}

fun getAvatarSharees(file: OCFile, user: User?, userId: String?, onComplete: (List<ShareeUser>) -> Unit) {
scope.launch {
val credentials = user?.toServerCredentials()
val result = if (credentials != null && credentials.supportsUnifiedShare()) {
ShareAvatarRepository(
credentials
).fetchShareAvatars(file.localId.toString())?.toAvatarSharees().orEmpty()
} else {
file.toLocalSharees(userId)
}

withContext(Dispatchers.Main) {
onComplete(result)
}
}
}

private fun OCFile.toLocalSharees(userId: String?): List<ShareeUser> {
val ownerSharee = ownerId
?.takeIf { it.isNotEmpty() && it != userId }
?.let { ShareeUser(it, ownerDisplayName, ShareType.USER) }
?.takeIf { it !in sharees }

return listOfNotNull(ownerSharee) + sharees.asReversed()
}

private fun List<Share>.toAvatarSharees(): List<ShareeUser> = asSequence()
.flatMap { share -> share.invitedRecipients }
.distinctBy { recipient -> recipient.value }
.map { recipient -> ShareeUser(recipient.value, recipient.displayName, ShareType.USER) }
.toList()

suspend fun prepareFileList(
directory: OCFile,
dataProvider: OCFileListAdapterDataProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import android.content.ContentValues
import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.account.User
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.share.UnifiedShareSharees
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
Expand Down Expand Up @@ -133,7 +134,7 @@ class OCFileListSearchTask(
resultData,
storageManager,
currentUser.accountName
)
).also { UnifiedShareSharees.fill(currentUser, it) }
} else {
parseAndSaveVirtuals(resultData, fragment)
}
Expand Down Expand Up @@ -216,6 +217,7 @@ class OCFileListSearchTask(
var ocFile = FileStorageUtils.fillOCFile(remoteFile)
FileStorageUtils.searchForLocalFileInDefaultPath(ocFile, currentUser.accountName)
resolveLocalFileId(ocFile)
UnifiedShareSharees.fill(currentUser, listOf(ocFile))
ocFile = storageManager.saveFileWithParent(ocFile, activity)
ocFile = handleEncryptionIfNeeded(ocFile, storageManager, activity) {
cachedClient ?: currentUser.toPlatformAccount().also { cachedClient = it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.nextcloud.client.di.Injectable
import com.nextcloud.client.logger.Logger
import com.nextcloud.common.SessionTimeOut
import com.owncloud.android.R
import com.nextcloud.utils.share.UnifiedShareSharees
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.operations.RemoteOperation
import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
Expand Down Expand Up @@ -88,6 +89,7 @@ class SharedListFragment :
parentId = partialFile.parentId
}
FileStorageUtils.searchForLocalFileInDefaultPath(file, user.accountName)
UnifiedShareSharees.fill(user, listOf(file))
val savedFile = containerActivity.storageManager.saveFileWithParent(file, context)
savedFile
} else {
Expand Down
Loading
Loading