diff --git a/app/src/main/java/com/nextcloud/utils/share/UnifiedShareSharees.kt b/app/src/main/java/com/nextcloud/utils/share/UnifiedShareSharees.kt new file mode 100644 index 000000000000..abd6bd70ab68 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/share/UnifiedShareSharees.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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() + + suspend fun fill(user: User, files: List) { + 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) { + 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.toAvatarSharees(): List = asSequence() + .flatMap { share -> share.invitedRecipients } + .distinctBy { recipient -> recipient.value } + .map { recipient -> ShareeUser(recipient.value, recipient.displayName, ShareType.USER) } + .toList() +} diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index 85af19137bc7..8245d79b3b63 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -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; @@ -629,6 +630,8 @@ private void synchronizeData(List folderAndFiles) { updateFileNameForEncryptedFile(fileDataStorageManager, metadata, mLocalFolder); } + UnifiedShareSharees.fillBlocking(user, updatedFiles); + fileDataStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values()); mChildren = updatedFiles; diff --git a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java index 57edd26105c3..da3d859ced35 100644 --- a/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/SynchronizeFolderOperation.java @@ -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; @@ -349,6 +350,8 @@ private void synchronizeData(List 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); diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt index 27beb655fb0f..fc893589c707 100644 --- a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -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? = null + init { checkNotNull(borderDrawable) DrawableCompat.setTint(borderDrawable, ContextCompat.getColor(context, R.color.bg_default)) } @Suppress("LongMethod", "TooGenericExceptionCaught") - fun setAvatars(user: User, sharees: MutableList, viewThemeUtils: ViewThemeUtils) { + fun setAvatars(user: User, sharees: List, 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 @@ -102,7 +125,7 @@ class AvatarGroupLayout @JvmOverloads constructor( ) else -> { - avatar.tag = sharee + avatar.tag = "${sharee.userId}@$serverName" DisplayUtils.setAvatar( user, sharee.userId!!, diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index a17e690653e4..b65ecf532d4e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -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; @@ -135,6 +136,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter recommendedFiles = new ArrayList<>(); private RecommendedFilesAdapter recommendedFilesAdapter; private final OCFileListAdapterHelper helper = new OCFileListAdapterHelper(); + private final AvatarShareesProvider avatarShareesProvider = new AvatarShareesProvider(); private final ThumbnailGenerator thumbnailGenerator; public OCFileListAdapter( @@ -601,6 +603,7 @@ 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(); @@ -608,16 +611,11 @@ private void bindSharedAvatars(ListItemViewHolder holder, OCFile file) { 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) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/helper/AvatarShareesProvider.kt b/app/src/main/java/com/owncloud/android/ui/adapter/helper/AvatarShareesProvider.kt new file mode 100644 index 000000000000..613c65c25fdb --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/helper/AvatarShareesProvider.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 { + 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) + } + } + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt index 68bbd807c576..84aaa80c90da 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt @@ -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 @@ -61,38 +54,6 @@ class OCFileListAdapterHelper { } } - fun getAvatarSharees(file: OCFile, user: User?, userId: String?, onComplete: (List) -> 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 { - 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.toAvatarSharees(): List = 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, diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt index 3e54e20630e9..04a37b8fd439 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListSearchTask.kt @@ -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 @@ -133,7 +134,7 @@ class OCFileListSearchTask( resultData, storageManager, currentUser.accountName - ) + ).also { UnifiedShareSharees.fill(currentUser, it) } } else { parseAndSaveVirtuals(resultData, fragment) } @@ -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 } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt index 2f9b050622cf..ceeb1c4082fb 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/SharedListFragment.kt @@ -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 @@ -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 { diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index a678575caba3..49560ab71e08 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -44,12 +44,11 @@ import com.nextcloud.client.account.User; import com.owncloud.android.MainApp; import com.owncloud.android.R; -import com.owncloud.android.datamodel.ArbitraryDataProvider; -import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; import com.owncloud.android.datamodel.ThumbnailsCacheManager; import com.owncloud.android.lib.common.OwnCloudAccount; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.ui.TextDrawable; +import com.owncloud.android.ui.adapter.helper.AvatarShareesProvider; import com.owncloud.android.ui.dialog.SortingOrderDialogFragment; import java.io.BufferedReader; @@ -443,30 +442,24 @@ public static void setAvatar(@NonNull User user, avatar.setTint(tintColor); } } else { - ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); - String eTag = arbitraryDataProvider.getValue(userId + "@" + serverName, ThumbnailsCacheManager.AVATAR); - String avatarKey = "a_" + userId + "_" + serverName + "_" + eTag; - - // first show old one - avatar = BitmapUtils.bitmapToCircularBitmapDrawable(resources, - ThumbnailsCacheManager.getBitmapFromDiskCache(avatarKey)); - - // if no one exists, show colored icon with initial char - if (avatar == null) { - try { - avatar = TextDrawable.createAvatarByUserId(displayName, - (avatarRadius - avatarBorder)); - } catch (Exception e) { - Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e); - avatar = ResourcesCompat.getDrawable(resources, - R.drawable.account_circle_white, - null); - } + // show colored icon with initial char until the cached avatar has been read from disk + try { + avatar = TextDrawable.createAvatarByUserId(displayName, + (avatarRadius - avatarBorder)); + } catch (Exception e) { + Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e); + avatar = ResourcesCompat.getDrawable(resources, + R.drawable.account_circle_white, + null); } } listener.avatarGenerated(avatar, callContext); + if (!userId.isEmpty()) { + AvatarShareesProvider.showCachedAvatar(userId, serverName, listener, resources, callContext, context); + } + // check for new avatar, eTag is compared, so only new one is downloaded final ThumbnailsCacheManager.AvatarGenerationTask task = new ThumbnailsCacheManager.AvatarGenerationTask(listener,