diff --git a/app/src/main/java/com/nextcloud/model/ShareeEntry.kt b/app/src/main/java/com/nextcloud/model/ShareeEntry.kt new file mode 100644 index 000000000000..05e9a6ae1946 --- /dev/null +++ b/app/src/main/java/com/nextcloud/model/ShareeEntry.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.model + +import android.content.ContentValues +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.lib.resources.shares.ShareType + +data class ShareeEntry( + val filePath: String?, + val accountOwner: String, + val fileOwnerId: String?, + val shareWithDisplayName: String?, + val shareWithUserId: String?, + val shareType: Int +) { + companion object { + /** + * Extracts a list of share-related ContentValues from a given RemoteFile. + * + * Each RemoteFile can be shared with multiple users (sharees), and this function converts each + * sharee into a ContentValues object, representing a row for insertion into a database. + * + * @param remoteFile The RemoteFile object containing sharee information. + * @param accountName The name of the user account that owns this RemoteFile. + * @return A list of ContentValues representing each share entry, or null if no sharees are found. + */ + fun getContentValues(remoteFile: RemoteFile, accountName: String): List? { + if (remoteFile.sharees.isNullOrEmpty()) { + return null + } + + val result = arrayListOf() + + for (share in remoteFile.sharees) { + val shareType: ShareType? = share?.shareType + if (shareType == null) { + continue + } + + val contentValue = ShareeEntry( + remoteFile.remotePath, + accountName, + remoteFile.ownerId, + share.displayName, + share.userId, + shareType.value + ).toContentValues() + + result.add(contentValue) + } + + return result + } + } + + private fun toContentValues(): ContentValues = ContentValues().apply { + put(ProviderTableMeta.OCSHARES_PATH, filePath) + put(ProviderTableMeta.OCSHARES_ACCOUNT_OWNER, accountOwner) + put(ProviderTableMeta.OCSHARES_USER_ID, fileOwnerId) + put(ProviderTableMeta.OCSHARES_SHARE_WITH_DISPLAY_NAME, shareWithDisplayName) + put(ProviderTableMeta.OCSHARES_SHARE_WITH, shareWithUserId) + put(ProviderTableMeta.OCSHARES_SHARE_TYPE, shareType) + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index a36acef5b5b9..1758d033eede 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -44,6 +44,7 @@ import com.nextcloud.model.OCFileFilterType; import com.nextcloud.model.OfflineOperationRawType; import com.nextcloud.model.OfflineOperationType; +import com.nextcloud.model.ShareeEntry; import com.nextcloud.utils.date.DateFormatPattern; import com.nextcloud.utils.extensions.DateExtensionsKt; import com.owncloud.android.MainApp; @@ -111,6 +112,7 @@ public class FileDataStorageManager { public final FileDao fileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).fileDao(); private final Gson gson = new Gson(); public final OfflineOperationsRepositoryType offlineOperationsRepository; + private final static int DEFAULT_CURSOR_INT_VALUE = -1; public FileDataStorageManager(User user, ContentResolver contentResolver) { this.contentProviderClient = null; @@ -1563,13 +1565,7 @@ private ContentValues createContentValueForShare(OCShare share) { contentValues.put(ProviderTableMeta.OCSHARES_SHARE_LABEL, share.getLabel()); FileDownloadLimit downloadLimit = share.getFileDownloadLimit(); - if (downloadLimit != null) { - contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT, downloadLimit.getLimit()); - contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT, downloadLimit.getCount()); - } else { - contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT); - contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT); - } + setDownloadLimitToContentValues(contentValues, downloadLimit); contentValues.put(ProviderTableMeta.OCSHARES_ATTRIBUTES, share.getAttributes()); @@ -1598,33 +1594,60 @@ private OCShare createShareInstance(Cursor cursor) { share.setShareLink(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_LINK)); share.setLabel(getString(cursor, ProviderTableMeta.OCSHARES_SHARE_LABEL)); - FileDownloadLimit downloadLimit = new FileDownloadLimit(token, - getInt(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT), - getInt(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT)); - share.setFileDownloadLimit(downloadLimit); + FileDownloadLimit fileDownloadLimit = getDownloadLimitFromCursor(cursor, token); + if (fileDownloadLimit != null) { + share.setFileDownloadLimit(fileDownloadLimit); + } share.setAttributes(getString(cursor, ProviderTableMeta.OCSHARES_ATTRIBUTES)); return share; } - private void resetShareFlagsInAllFiles() { - ContentValues cv = new ContentValues(); - cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, Boolean.FALSE); - cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, Boolean.FALSE); - String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + "=?"; - String[] whereArgs = new String[]{user.getAccountName()}; + private void setDownloadLimitToContentValues(ContentValues contentValues, FileDownloadLimit downloadLimit) { + if (downloadLimit != null) { + contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT, downloadLimit.getLimit()); + contentValues.put(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT, downloadLimit.getCount()); + return; + } - if (getContentResolver() != null) { - getContentResolver().update(ProviderTableMeta.CONTENT_URI, cv, where, whereArgs); + contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT); + contentValues.putNull(ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT); + } - } else { - try { - getContentProviderClient().update(ProviderTableMeta.CONTENT_URI, cv, where, whereArgs); - } catch (RemoteException e) { - Log_OC.e(TAG, "Exception in resetShareFlagsInAllFiles" + e.getMessage(), e); - } + @Nullable + private FileDownloadLimit getDownloadLimitFromCursor(Cursor cursor, String token) { + if (token == null || cursor == null) { + return null; } + + int limit = getIntOrDefault(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_LIMIT); + int count = getIntOrDefault(cursor, ProviderTableMeta.OCSHARES_DOWNLOADLIMIT_COUNT); + if (limit != DEFAULT_CURSOR_INT_VALUE && count != DEFAULT_CURSOR_INT_VALUE) { + return new FileDownloadLimit(token, limit, count); + } + + return null; + } + + /** + * Retrieves an integer value from the specified column in the cursor. + *

+ * If the column does not exist (i.e., {@code cursor.getColumnIndex(columnName)} returns -1), + * this method returns {@code -1} as a default value. + *

+ * + * @param cursor The Cursor from which to retrieve the value. + * @param columnName The name of the column to retrieve the integer from. + * @return The integer value from the column, or {@code -1} if the column is not found. + */ + private int getIntOrDefault(Cursor cursor, String columnName) { + int index = cursor.getColumnIndex(columnName); + if (index == DEFAULT_CURSOR_INT_VALUE) { + return DEFAULT_CURSOR_INT_VALUE; + } + + return cursor.getInt(index); } private void resetShareFlagsInFolder(OCFile folder) { @@ -1743,6 +1766,67 @@ public void removeShare(OCShare share) { } } + public void saveSharesFromRemoteFile(List shares) { + if (shares == null || shares.isEmpty()) { + return; + } + + // Prepare reset operations + Set uniquePaths = new HashSet<>(); + for (RemoteFile share : shares) { + uniquePaths.add(share.getRemotePath()); + } + + ArrayList resetOperations = new ArrayList<>(); + for (String path : uniquePaths) { + resetShareFlagInAFile(path); + var removeOps = prepareRemoveSharesInFile(path, new ArrayList<>()); + if (!removeOps.isEmpty()) { + resetOperations.addAll(removeOps); + } + } + if (!resetOperations.isEmpty()) { + applyBatch(resetOperations); + } + + // Prepare insert operations + ArrayList insertOperations = prepareInsertSharesFromRemoteFile(shares); + if (!insertOperations.isEmpty()) { + applyBatch(insertOperations); + } + } + + /** + * Prepares a list of ContentProviderOperation insert operations based on share information + * found in the given iterable of RemoteFile objects. + *

+ * Each RemoteFile may have multiple share entries (sharees), and for each one, + * a corresponding ContentProviderOperation is created for insertion into the shares table. + * + * @param remoteFiles An iterable list of RemoteFile objects containing sharee data. + * @return A list of ContentProviderOperation objects for batch insertion into the content provider. + */ + private ArrayList prepareInsertSharesFromRemoteFile(Iterable remoteFiles) { + final ArrayList contentValueList = new ArrayList<>(); + for (RemoteFile remoteFile : remoteFiles) { + final var contentValues = ShareeEntry.Companion.getContentValues(remoteFile, user.getAccountName()); + if (contentValues == null) { + continue; + } + contentValueList.addAll(contentValues); + } + + ArrayList operations = new ArrayList<>(); + for (ContentValues contentValues : contentValueList) { + operations.add(ContentProviderOperation + .newInsert(ProviderTableMeta.CONTENT_URI_SHARE) + .withValues(contentValues) + .build()); + } + + return operations; + } + public void saveSharesDB(List shares) { ArrayList operations = new ArrayList<>(); @@ -1759,20 +1843,26 @@ public void saveSharesDB(List shares) { // Add operations to insert shares operations = prepareInsertShares(shares, operations); + if (operations.isEmpty()) { + return; + } + // apply operations in batch - if (operations.size() > 0) { - Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); - try { - if (getContentResolver() != null) { - getContentResolver().applyBatch(MainApp.getAuthority(), operations); + Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); + applyBatch(operations); + } - } else { - getContentProviderClient().applyBatch(operations); - } + private void applyBatch(ArrayList operations) { + try { + if (getContentResolver() != null) { + getContentResolver().applyBatch(MainApp.getAuthority(), operations); - } catch (OperationApplicationException | RemoteException e) { - Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); + } else { + getContentProviderClient().applyBatch(operations); } + + } catch (OperationApplicationException | RemoteException e) { + Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); } } @@ -1830,8 +1920,7 @@ public void saveSharesInFolder(ArrayList shares, OCFile folder) { * @param operations List of operations * @return */ - private ArrayList prepareInsertShares( - Iterable shares, ArrayList operations) { + private ArrayList prepareInsertShares(Iterable shares, ArrayList operations) { ContentValues contentValues; // prepare operations to insert or update files to save in the given folder 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 5e93e1db652f..9e1704584193 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -231,6 +231,11 @@ protected RemoteOperationResult run(OwnCloudClient client) { mConflictsFound = 0; mForgottenLocalFiles.clear(); + if (mLocalFolder == null) { + Log_OC.e(TAG, "Local folder is null, cannot run refresh folder operation"); + return new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + } + if (OCFile.ROOT_PATH.equals(mLocalFolder.getRemotePath()) && !mSyncFullAccount && !mOnlyFileMetadata) { updateOCVersion(client); updateUserProfile(); @@ -253,19 +258,32 @@ protected RemoteOperationResult run(OwnCloudClient client) { mLocalFolder.setEtag(""); } - mLocalFolder.setLastSyncDateForData(System.currentTimeMillis()); - fileDataStorageManager.saveFile(mLocalFolder); + if (mLocalFolder != null) { + mLocalFolder.setLastSyncDateForData(System.currentTimeMillis()); + fileDataStorageManager.saveFile(mLocalFolder); + } else { + Log_OC.e(TAG, "Local folder is null, cannot set last sync date nor save file"); + result = new RemoteOperationResult<>(ResultCode.FILE_NOT_FOUND); + } } - if (!mSyncFullAccount && mRemoteFolderChanged) { + if (!mSyncFullAccount && mRemoteFolderChanged && mLocalFolder != null) { sendLocalBroadcast(EVENT_SINGLE_FOLDER_CONTENTS_SYNCED, mLocalFolder.getRemotePath(), result); } - if (result.isSuccess() && !mSyncFullAccount && !mOnlyFileMetadata) { - refreshSharesForFolder(client); // share result is ignored + if (result.isSuccess() && result.getData() != null && !mSyncFullAccount && !mOnlyFileMetadata) { + final var remoteObject = result.getData(); + final ArrayList remoteFiles = new ArrayList<>(); + for (Object object: remoteObject) { + if (object instanceof RemoteFile remoteFile) { + remoteFiles.add(remoteFile); + } + } + + fileDataStorageManager.saveSharesFromRemoteFile(remoteFiles); } - if (!mSyncFullAccount) { + if (!mSyncFullAccount && mLocalFolder != null) { sendLocalBroadcast(EVENT_SINGLE_FOLDER_SHARES_SYNCED, mLocalFolder.getRemotePath(), result); } @@ -781,16 +799,6 @@ private void startContentSynchronizations(List filesTo } } - /** - * Syncs the Share resources for the files contained in the folder refreshed (children, not deeper descendants). - * - * @param client Handler of a session with an OC server. - */ - private void refreshSharesForFolder(OwnCloudClient client) { - GetSharesForFileOperation operation = new GetSharesForFileOperation(mLocalFolder.getRemotePath(), true, true, fileDataStorageManager); - operation.execute(client); - } - /** * Sends a message to any application component interested in the progress of the synchronization. * diff --git a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java index 016f81f24ae8..19e5fc0b2f4a 100644 --- a/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java +++ b/app/src/main/java/com/owncloud/android/providers/FileContentProvider.java @@ -345,7 +345,13 @@ private Uri insert(SupportSQLiteDatabase db, Uri uri, ContentValues values) { private void updateFilesTableAccordingToShareInsertion(SupportSQLiteDatabase db, ContentValues newShare) { ContentValues fileValues = new ContentValues(); - ShareType newShareType = ShareType.fromValue(newShare.getAsInteger(ProviderTableMeta.OCSHARES_SHARE_TYPE)); + Integer shareTypeValue = newShare.getAsInteger(ProviderTableMeta.OCSHARES_SHARE_TYPE); + if (shareTypeValue == null) { + Log_OC.w(TAG, "Share type is null. Skipping file update."); + return; + } + + ShareType newShareType = ShareType.fromValue(shareTypeValue); switch (newShareType) { case PUBLIC_LINK: diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.java b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.java deleted file mode 100644 index 91dbd0f3f35b..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Andy Scherzinger - * @author Stefan Niedermann - * Copyright (C) 2021 Andy Scherzinger - * Copyright (C) 2021 Stefan Niedermann - * - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ - -package com.owncloud.android.ui; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.RelativeLayout; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.target.BitmapImageViewTarget; -import com.nextcloud.client.account.User; -import com.owncloud.android.R; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.shares.ShareeUser; -import com.owncloud.android.utils.DisplayUtils; -import com.owncloud.android.utils.theme.ViewThemeUtils; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Px; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.ResourcesCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.graphics.drawable.RoundedBitmapDrawable; -import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; - -public class AvatarGroupLayout extends RelativeLayout implements DisplayUtils.AvatarGenerationListener { - private static final String TAG = AvatarGroupLayout.class.getSimpleName(); - - private final static int MAX_AVATAR_COUNT = 3; - - private final Drawable borderDrawable; - @Px private final int avatarSize; - @Px private final int avatarBorderSize; - @Px private final int overlapPx; - - public AvatarGroupLayout(Context context) { - this(context, null); - } - - public AvatarGroupLayout(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public AvatarGroupLayout(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - public AvatarGroupLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - avatarBorderSize = DisplayUtils.convertDpToPixel(2, context); - avatarSize = DisplayUtils.convertDpToPixel(40, context); - overlapPx = DisplayUtils.convertDpToPixel(24, context); - borderDrawable = ContextCompat.getDrawable(context, R.drawable.round_bgnd); - assert borderDrawable != null; - DrawableCompat.setTint(borderDrawable, ContextCompat.getColor(context, R.color.bg_default)); - } - - public void setAvatars(@NonNull User user, - @NonNull List sharees, - final ViewThemeUtils viewThemeUtils) { - @NonNull Context context = getContext(); - removeAllViews(); - RelativeLayout.LayoutParams avatarLayoutParams; - int avatarCount; - int shareeSize = Math.min(sharees.size(), MAX_AVATAR_COUNT); - - Resources resources = context.getResources(); - float avatarRadius = resources.getDimension(R.dimen.list_item_avatar_icon_radius); - ShareeUser sharee; - - for (avatarCount = 0; avatarCount < shareeSize; avatarCount++) { - avatarLayoutParams = new RelativeLayout.LayoutParams(avatarSize, avatarSize); - avatarLayoutParams.setMargins(0, 0, avatarCount * overlapPx, 0); - avatarLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); - - final ImageView avatar = new ImageView(context); - avatar.setLayoutParams(avatarLayoutParams); - avatar.setPadding(avatarBorderSize, avatarBorderSize, avatarBorderSize, avatarBorderSize); - - avatar.setBackground(borderDrawable); - addView(avatar); - avatar.requestLayout(); - - if (avatarCount == 0 && sharees.size() > MAX_AVATAR_COUNT) { - avatar.setImageResource(R.drawable.ic_people); - viewThemeUtils.platform.tintTextDrawable(context, avatar.getDrawable()); - } else { - sharee = sharees.get(avatarCount); - switch (sharee.getShareType()) { - case GROUP: - case EMAIL: - case ROOM: - case CIRCLE: - viewThemeUtils.files.createAvatar(sharee.getShareType(), avatar, context); - break; - case FEDERATED: - showFederatedShareAvatar(context, - sharee.getUserId(), - avatarRadius, - resources, - avatar, - viewThemeUtils); - break; - default: - avatar.setTag(sharee); - DisplayUtils.setAvatar(user, - sharee.getUserId(), - sharee.getDisplayName(), - this, - avatarRadius, - resources, - avatar, - context); - break; - } - } - } - - // Recalculate container size based on avatar count - int size = overlapPx * (avatarCount - 1) + avatarSize; - ViewGroup.LayoutParams rememberParam = getLayoutParams(); - rememberParam.width = size; - setLayoutParams(rememberParam); - } - - private void showFederatedShareAvatar(Context context, - String user, - float avatarRadius, - Resources resources, - ImageView avatar, - ViewThemeUtils viewThemeUtils) { - // maybe federated share - String[] split = user.split("@"); - String userId = split[0]; - String server = split[1]; - - String url = "https://" + server + "/index.php/avatar/" + userId + "/" + - resources.getInteger(R.integer.file_avatar_px); - - Drawable placeholder; - try { - placeholder = TextDrawable.createAvatarByUserId(userId, avatarRadius); - } catch (Exception e) { - Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e); - placeholder = viewThemeUtils.platform.colorDrawable(ResourcesCompat.getDrawable(resources, - R.drawable.account_circle_white, - null), - ContextCompat.getColor(context, R.color.black)); - } - - avatar.setTag(null); - Glide.with(context).load(url) - .asBitmap() - .placeholder(placeholder) - .error(placeholder) - .into(new BitmapImageViewTarget(avatar) { - @Override - protected void setResource(Bitmap resource) { - RoundedBitmapDrawable circularBitmapDrawable = RoundedBitmapDrawableFactory.create(resources, - resource); - circularBitmapDrawable.setCircular(true); - avatar.setImageDrawable(circularBitmapDrawable); - } - }); - } - - @Override - public void avatarGenerated(Drawable avatarDrawable, Object callContext) { - ((ImageView) callContext).setImageDrawable(avatarDrawable); - } - - @Override - public boolean shouldCallGeneratedCallback(String tag, Object callContext) { - return ((ImageView) callContext).getTag().equals(tag); - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt new file mode 100644 index 000000000000..82bf5ae6bbb9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/AvatarGroupLayout.kt @@ -0,0 +1,193 @@ +/* + * Nextcloud Android client application + * + * @author Andy Scherzinger + * @author Stefan Niedermann + * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2021 Stefan Niedermann + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.annotation.Px +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.BitmapImageViewTarget +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.lib.resources.shares.ShareeUser +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlin.math.min + +@Suppress("MagicNumber") +class AvatarGroupLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes), + AvatarGenerationListener { + private val borderDrawable = ContextCompat.getDrawable(context, R.drawable.round_bgnd) + + @Px + private val avatarSize: Int = DisplayUtils.convertDpToPixel(40f, context) + + @Px + private val avatarBorderSize: Int = DisplayUtils.convertDpToPixel(2f, context) + + @Px + private val overlapPx: Int = DisplayUtils.convertDpToPixel(24f, context) + + init { + checkNotNull(borderDrawable) + DrawableCompat.setTint(borderDrawable, ContextCompat.getColor(context, R.color.bg_default)) + } + + @Suppress("LongMethod", "TooGenericExceptionCaught") + fun setAvatars(user: User, sharees: MutableList, viewThemeUtils: ViewThemeUtils) { + val context = getContext() + removeAllViews() + 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) + var sharee: ShareeUser + + var avatarCount = 0 + while (avatarCount < shareeSize) { + avatarLayoutParams = LayoutParams(avatarSize, avatarSize).apply { + setMargins(0, 0, avatarCount * overlapPx, 0) + addRule(ALIGN_PARENT_RIGHT) + } + + val avatar = ImageView(context).apply { + layoutParams = avatarLayoutParams + setPadding(avatarBorderSize, avatarBorderSize, avatarBorderSize, avatarBorderSize) + background = borderDrawable + } + + addView(avatar) + avatar.requestLayout() + + if (avatarCount == 0 && sharees.size > MAX_AVATAR_COUNT) { + avatar.setImageResource(R.drawable.ic_people) + viewThemeUtils.platform.tintDrawable(context, avatar.drawable, ColorRole.ON_SURFACE) + } else { + sharee = sharees[avatarCount] + when (sharee.shareType) { + ShareType.GROUP, ShareType.EMAIL, ShareType.ROOM, ShareType.CIRCLE -> + viewThemeUtils.files.createAvatar( + sharee.shareType, + avatar, + context + ) + + ShareType.FEDERATED -> showFederatedShareAvatar( + context, + sharee.userId!!, + avatarRadius, + resources, + avatar, + viewThemeUtils + ) + + else -> { + avatar.tag = sharee + DisplayUtils.setAvatar( + user, + sharee.userId!!, + sharee.displayName, + this, + avatarRadius, + resources, + avatar, + context + ) + } + } + } + avatarCount++ + } + + // Recalculate container size based on avatar count + val size = overlapPx * (avatarCount - 1) + avatarSize + val rememberParam = layoutParams + rememberParam.width = size + layoutParams = rememberParam + } + + @Suppress("TooGenericExceptionCaught") + private fun showFederatedShareAvatar( + context: Context, + user: String, + avatarRadius: Float, + resources: Resources, + avatar: ImageView, + viewThemeUtils: ViewThemeUtils + ) { + // maybe federated share + val split = user.split("@".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val userId: String? = split[0] + val server = split[1] + + val url = "https://" + server + "/index.php/avatar/" + userId + "/" + + resources.getInteger(R.integer.file_avatar_px) + var placeholder: Drawable? + try { + placeholder = TextDrawable.createAvatarByUserId(userId, avatarRadius) + } catch (e: Exception) { + Log_OC.e(TAG, "Error calculating RGB value for active account icon.", e) + placeholder = viewThemeUtils.platform.colorDrawable( + ResourcesCompat.getDrawable( + resources, + R.drawable.account_circle_white, + null + )!!, + ContextCompat.getColor(context, R.color.black) + ) + } + + avatar.tag = null + Glide.with(context).load(url) + .asBitmap() + .placeholder(placeholder) + .error(placeholder) + .into(object : BitmapImageViewTarget(avatar) { + override fun setResource(resource: Bitmap?) { + resource?.let { + val circularBitmapDrawable = RoundedBitmapDrawableFactory.create(resources, it) + circularBitmapDrawable.isCircular = true + avatar.setImageDrawable(circularBitmapDrawable) + } + } + }) + } + + override fun avatarGenerated(avatarDrawable: Drawable?, callContext: Any) { + (callContext as ImageView).setImageDrawable(avatarDrawable) + } + + override fun shouldCallGeneratedCallback(tag: String?, callContext: Any): Boolean = + (callContext as ImageView).tag == tag + + companion object { + private val TAG: String = AvatarGroupLayout::class.java.simpleName + private const val MAX_AVATAR_COUNT = 3 + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java index d4b066f9b81d..095cb4fe14bd 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java @@ -66,7 +66,6 @@ import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.IntentExtensionsKt; -import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.fileNameValidator.FileNameValidator; import com.nextcloud.utils.view.FastScrollUtils; import com.owncloud.android.MainApp; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt index 186e628e7f8a..affc513870b7 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCShareToOCFileConverter.kt @@ -63,7 +63,13 @@ object OCShareToOCFileConverter { file.isSharedWithSharee = true file.sharees = shares .filter { it.shareType != ShareType.PUBLIC_LINK && it.shareType != ShareType.EMAIL } - .map { ShareeUser(it.shareWith, it.sharedWithDisplayName, it.shareType) } + .map { + ShareeUser( + userId = it.userId, + displayName = it.sharedWithDisplayName, + shareType = it.shareType + ) + } } return file } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java index 3f99c825c388..bb9f7e11dc47 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailSharingFragment.java @@ -30,6 +30,9 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.LinearLayout; import com.nextcloud.client.account.User; import com.nextcloud.client.account.UserAccountManager; @@ -61,6 +64,8 @@ import com.owncloud.android.ui.adapter.ShareeListAdapterListener; import com.owncloud.android.ui.asynctasks.RetrieveHoverCardAsyncTask; import com.owncloud.android.ui.dialog.SharePasswordDialogFragment; +import com.owncloud.android.ui.fragment.share.RemoteShareRepository; +import com.owncloud.android.ui.fragment.share.ShareRepository; import com.owncloud.android.ui.fragment.util.FileDetailSharingFragmentHelper; import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.utils.ClipboardUtil; @@ -82,6 +87,7 @@ import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import kotlin.Unit; public class FileDetailSharingFragment extends Fragment implements ShareeListAdapterListener, DisplayUtils.AvatarGenerationListener, @@ -150,22 +156,45 @@ public void onCreate(@Nullable Bundle savedInstanceState) { if (fileActivity == null) { throw new IllegalArgumentException("FileActivity may not be null"); } + + fileDataStorageManager = fileActivity.getStorageManager(); + fetchSharees(); + } + + private void fetchSharees() { + ShareRepository shareRepository = new RemoteShareRepository(fileActivity.getClientRepository(), fileActivity, fileDataStorageManager); + shareRepository.fetchSharees(file.getRemotePath(), () -> { + refreshCapabilitiesFromDB(); + refreshSharesFromDB(); + showShareContainer(); + return Unit.INSTANCE; + }, () -> { + showShareContainer(); + DisplayUtils.showSnackMessage(getView(), R.string.error_fetching_sharees); + return Unit.INSTANCE; + }); } - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + private void showShareContainer() { + if (binding == null) { + return; + } - refreshCapabilitiesFromDB(); - refreshSharesFromDB(); + final LinearLayout shimmerLayout = binding.shimmerLayout.getRoot(); + shimmerLayout.clearAnimation(); + shimmerLayout.setVisibility(View.GONE); + + binding.shareContainer.setVisibility(View.VISIBLE); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FileDetailsSharingFragmentBinding.inflate(inflater, container, false); + final Animation blinkAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.blink); + binding.shimmerLayout.getRoot().startAnimation(blinkAnimation); + fileOperationsHelper = fileActivity.getFileOperationsHelper(); - fileDataStorageManager = fileActivity.getStorageManager(); AccountManager accountManager = AccountManager.get(requireContext()); String userId = accountManager.getUserData(user.toPlatformAccount(), @@ -196,7 +225,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, SharesType.EXTERNAL); externalShareeListAdapter.setHasStableIds(true); - + binding.sharesListExternal.setAdapter(externalShareeListAdapter); binding.sharesListExternal.setLayoutManager(new LinearLayoutManager(requireContext())); diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailsSharingProcessFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailsSharingProcessFragment.kt index 807accfdbb43..1b7c812ef204 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailsSharingProcessFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailsSharingProcessFragment.kt @@ -166,7 +166,7 @@ class FileDetailsSharingProcessFragment : permission = share?.permissions ?: capabilities.defaultPermissions - ?: SharePermissionManager.getMaximumPermission(isFolder()) + ?: SharePermissionManager.getMaximumPermission(isFolder()) } private fun initArguments() { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 85c3bf0e4c75..eac91ed8934f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -277,7 +277,9 @@ public void onResume() { handleSearchEvent(searchEvent); } - refreshDirectory(); + if (getActivity() instanceof FileDisplayActivity fda) { + fda.startSyncFolderOperation(getCurrentFile(), true); + } super.onResume(); } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/share/RemoteShareRepository.kt b/app/src/main/java/com/owncloud/android/ui/fragment/share/RemoteShareRepository.kt new file mode 100644 index 000000000000..2a371c1bcbe7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/share/RemoteShareRepository.kt @@ -0,0 +1,59 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.share + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.nextcloud.repository.ClientRepository +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.GetSharesForFileOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RemoteShareRepository( + private val clientRepository: ClientRepository, + lifecycleOwner: LifecycleOwner, + private val fileDataStorageManager: FileDataStorageManager +) : ShareRepository { + private val tag = "RemoteShareRepository" + private val scope = lifecycleOwner.lifecycleScope + + override fun fetchSharees(remotePath: String, onCompleted: () -> Unit, onError: () -> Unit) { + scope.launch(Dispatchers.IO) { + val client = clientRepository.getOwncloudClient() ?: return@launch + val operation = + GetSharesForFileOperation( + path = remotePath, + reshares = true, + subfiles = false, + storageManager = fileDataStorageManager + ) + + @Suppress("DEPRECATION") + val result = operation.execute(client) + + Log_OC.i(tag, "Remote path for the refresh shares: $remotePath") + + withContext(Dispatchers.Main) { + if (result.isSuccess) { + Log_OC.d(tag, "Successfully refreshed shares for the specified remote path.") + onCompleted() + } else { + Log_OC.w( + tag, + "Failed to refresh shares for the specified remote path. " + + "An error occurred during the operation." + ) + onError() + } + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/share/ShareRepository.kt b/app/src/main/java/com/owncloud/android/ui/fragment/share/ShareRepository.kt new file mode 100644 index 000000000000..1f34ef5f11ca --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/share/ShareRepository.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.share + +interface ShareRepository { + fun fetchSharees(remotePath: String, onCompleted: () -> Unit, onError: () -> Unit) +} 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 ebc9e12a9d3a..c609d312de5c 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -486,30 +486,39 @@ public static void setAvatar(@NonNull User user, Resources resources, Object callContext, Context context) { - if (callContext instanceof View) { - ((View) callContext).setContentDescription(String.valueOf(user.toPlatformAccount().hashCode())); + if (callContext instanceof View v) { + v.setContentDescription(String.valueOf(user.toPlatformAccount().hashCode())); } - ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); - final String accountName = user.getAccountName(); String serverName = accountName.substring(accountName.lastIndexOf('@') + 1); - String eTag = arbitraryDataProvider.getValue(userId + "@" + serverName, ThumbnailsCacheManager.AVATAR); - String avatarKey = "a_" + userId + "_" + serverName + "_" + eTag; - - // first show old one - Drawable 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); - } 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); + Drawable avatar; + + if (userId.isEmpty()) { + avatar = ContextCompat.getDrawable(context, R.drawable.ic_link); + if (avatar != null) { + int tintColor = ContextCompat.getColor(context, R.color.icon_on_nc_grey); + 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); + } 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); + } } } diff --git a/app/src/main/res/anim/blink.xml b/app/src/main/res/anim/blink.xml new file mode 100644 index 000000000000..9a7ff11850a3 --- /dev/null +++ b/app/src/main/res/anim/blink.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/circle_shimmer.xml b/app/src/main/res/layout/circle_shimmer.xml new file mode 100644 index 000000000000..c9e0674556c4 --- /dev/null +++ b/app/src/main/res/layout/circle_shimmer.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/main/res/layout/file_details_sharing_fragment.xml b/app/src/main/res/layout/file_details_sharing_fragment.xml index adb3a65bf77e..febc93d0d0f3 100644 --- a/app/src/main/res/layout/file_details_sharing_fragment.xml +++ b/app/src/main/res/layout/file_details_sharing_fragment.xml @@ -12,182 +12,195 @@ android:layout_height="match_parent" android:paddingTop="@dimen/standard_eight_padding"> - + android:layout_height="match_parent" + android:layout_below="@id/appbar"> - - + android:visibility="gone" + tools:visibility="visible" + android:orientation="vertical"> - + - - + android:paddingRight="@dimen/standard_padding"> + android:textSize="@dimen/two_line_primary_text_size" /> + + + + + + + + + + + + + + + + + - - - - - - - - + android:text="@string/internal_shares" + android:textAppearance="?android:attr/textAppearanceMedium" /> - + - + - + - + - + - - - + + + - - - + + + + - + + + - - diff --git a/app/src/main/res/layout/file_details_sharing_shimmer.xml b/app/src/main/res/layout/file_details_sharing_shimmer.xml new file mode 100644 index 000000000000..f679386e4a1b --- /dev/null +++ b/app/src/main/res/layout/file_details_sharing_shimmer.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/loading_text_shimmer.xml b/app/src/main/res/layout/loading_text_shimmer.xml new file mode 100644 index 000000000000..63de923c539b --- /dev/null +++ b/app/src/main/res/layout/loading_text_shimmer.xml @@ -0,0 +1,19 @@ + + + diff --git a/app/src/main/res/layout/share_list_item_shimmer.xml b/app/src/main/res/layout/share_list_item_shimmer.xml new file mode 100644 index 000000000000..b1d162ecfdd7 --- /dev/null +++ b/app/src/main/res/layout/share_list_item_shimmer.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 057b3692ab83..397e56612dc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -885,6 +885,7 @@ New folder Virus detected. Upload cannot be completed! Tags + Unable to fetch sharees. Adding sharee failed Adding share failed. This file or folder has already been shared with this person or group. Unsharing failed diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 98b8d9b2779c..af3386de5cab 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -75,6 +75,7 @@ + @@ -295,6 +296,7 @@ + @@ -12268,6 +12270,14 @@ + + + + + + + + @@ -15060,6 +15070,11 @@ + + + + +