diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt index 9eabce775330..2dbb2c370ad1 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/SharedListFragmentIT.kt @@ -21,8 +21,10 @@ import com.owncloud.android.AbstractIT import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.shares.OCShare import com.owncloud.android.lib.resources.shares.ShareType +import com.owncloud.android.ui.adapter.OCShareToOCFileConverter import com.owncloud.android.utils.EspressoIdlingResource import com.owncloud.android.utils.ScreenshotTest +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule @@ -165,14 +167,14 @@ internal class SharedListFragmentIT : AbstractIT() { fragment.isLoading = false fragment.mEmptyListContainer?.visibility = View.GONE - fragment.adapter.setData( - shares, - SearchType.SHARED_FILTER, - storageManager, - null, - true - ) + val newList = runBlocking { + OCShareToOCFileConverter.parseAndSaveShares(shares, storageManager, user.accountName) + } + fragment.adapter.run { + prepareForSearchData(storageManager, SearchType.SHARED_FILTER) + updateAdapter(newList, null) + } EspressoIdlingResource.decrement() val screenShotName = createName(testClassName + "_" + "showSharedFiles", "") diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt index 87ce9c999c68..a5bafa686668 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -21,6 +21,7 @@ import com.nextcloud.client.database.dao.FileDao import com.nextcloud.client.database.dao.FileSystemDao import com.nextcloud.client.database.dao.OfflineOperationDao import com.nextcloud.client.database.dao.RecommendedFileDao +import com.nextcloud.client.database.dao.ShareDao import com.nextcloud.client.database.dao.SyncedFolderDao import com.nextcloud.client.database.dao.UploadDao import com.nextcloud.client.database.entity.ArbitraryDataEntity @@ -106,6 +107,7 @@ abstract class NextcloudDatabase : RoomDatabase() { abstract fun fileSystemDao(): FileSystemDao abstract fun syncedFolderDao(): SyncedFolderDao abstract fun assistantDao(): AssistantDao + abstract fun shareDao(): ShareDao companion object { const val FIRST_ROOM_DB_VERSION = 65 diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index 3356d69d0be5..60fc48c23571 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -38,9 +38,15 @@ interface FileDao { @Query("SELECT * FROM filelist WHERE remote_id = :remoteId AND file_owner = :fileOwner LIMIT 1") fun getFileByRemoteId(remoteId: String, fileOwner: String): FileEntity? + @Query("SELECT * FROM filelist WHERE remote_id = :remoteId LIMIT 1") + suspend fun getFileByRemoteId(remoteId: String): FileEntity? + @Query("SELECT * FROM filelist WHERE parent = :parentId ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") fun getFolderContent(parentId: Long): List + @Query("SELECT * FROM filelist WHERE parent = :parentId ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") + suspend fun getFolderContentSuspended(parentId: Long): List + @Query( "SELECT * FROM filelist WHERE modified >= :startDate" + " AND modified < :endDate" + @@ -111,4 +117,33 @@ interface FileDao { """ ) fun searchFilesInFolder(parentId: Long, fileOwner: String, query: String): List + + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :accountName + AND ( + share_by_link = 1 + OR shared_via_users = 1 + OR permissions LIKE '%S%' + ) + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + suspend fun getSharedFiles(accountName: String): List + + @Query( + """ + SELECT * + FROM filelist + WHERE file_owner = :fileOwner + AND favorite = 1 + ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER} + """ + ) + suspend fun getFavoriteFiles(fileOwner: String): List + + @Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL") + fun getAllRemoteIds(accountName: String): List } diff --git a/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt new file mode 100644 index 000000000000..61ea4a09c54a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/dao/ShareDao.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.nextcloud.client.database.entity.ShareEntity + +@Dao +interface ShareDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(shares: List) + + @Query("DELETE FROM ocshares WHERE owner_share = :accountName") + suspend fun clearSharesForAccount(accountName: String) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt index 2fec3fcc12d8..6567d7c5b559 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -9,6 +9,19 @@ package com.nextcloud.utils.extensions import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.shares.OCShare +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun FileDataStorageManager.saveShares(shares: List, accountName: String) { + withContext(Dispatchers.IO) { + val entities = shares.map { share -> + share.toEntity(accountName) + } + + shareDao.insertAll(entities) + } +} fun FileDataStorageManager.searchFilesByName(file: OCFile, accountName: String, query: String): List = fileDao.searchFilesInFolder(file.fileId, accountName, query).map { diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt index 34f46ab66922..d19a586de530 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -11,9 +11,31 @@ import com.owncloud.android.MainApp import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.FileStorageUtils -fun List.filterFilenames(): List = distinctBy { it.fileName } +@Suppress("ReturnCount") +fun List.hasSameContentAs(other: List): Boolean { + if (this.size != other.size) return false + + if (this === other) return true + + for (i in this.indices) { + val a = this[i] + val b = other[i] + + if (a != b) return false + if (a.fileId != b.fileId) return false + if (a.etag != b.etag) return false + if (a.modificationTimestamp != b.modificationTimestamp) return false -fun List.filterTempFilter(): List = filterNot { it.isTempFile() } + if (a.fileLength != b.fileLength) return false + if (a.isFavorite != b.isFavorite) return false + + if (a.fileName != b.fileName) return false + } + + return true +} + +fun List.filterFilenames(): List = distinctBy { it.fileName } fun OCFile.isTempFile(): Boolean { val context = MainApp.getAppContext() @@ -21,17 +43,6 @@ fun OCFile.isTempFile(): Boolean { return storagePath?.startsWith(appTempPath) == true } -fun List.filterHiddenFiles(): List = filterNot { it.isHidden }.distinct() - -fun List.filterByMimeType(mimeType: String): List = - filter { it.isFolder || it.mimeType.startsWith(mimeType) } - -fun List.limitToPersonalFiles(userId: String): List = filter { file -> - file.ownerId?.let { ownerId -> - ownerId == userId && !file.isSharedWithMe && !file.mounted() - } == true -} - fun OCFile.mediaSize(defaultThumbnailSize: Float): Pair { val width = (imageDimension?.width?.toInt() ?: defaultThumbnailSize.toInt()) val height = (imageDimension?.height?.toInt() ?: defaultThumbnailSize.toInt()) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt index 9161bf9fbf69..dcb43b34dbfd 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCShareExtensions.kt @@ -7,8 +7,35 @@ package com.nextcloud.utils.extensions +import com.nextcloud.client.database.entity.ShareEntity import com.owncloud.android.lib.resources.shares.OCShare fun OCShare.hasFileRequestPermission(): Boolean = (isFolder && shareType?.isPublicOrMail() == true) fun List.mergeDistinctByToken(other: List): List = (this + other).distinctBy { it.token } + +fun OCShare.toEntity(accountName: String): ShareEntity = ShareEntity( + id = remoteId.toInt(), // so that db is not keep updating same files + idRemoteShared = remoteId.toInt(), + path = path, + itemSource = itemSource.toInt(), + fileSource = fileSource.toInt(), + shareType = shareType?.value, + shareWith = shareWith, + permissions = permissions, + sharedDate = sharedDate.toInt(), + expirationDate = expirationDate.toInt(), + token = token, + shareWithDisplayName = sharedWithDisplayName, + isDirectory = if (isFolder) 1 else 0, + userId = userId, + accountOwner = accountName, + isPasswordProtected = if (isPasswordProtected) 1 else 0, + note = note, + hideDownload = if (isHideFileDownload) 1 else 0, + shareLink = shareLink, + shareLabel = label, + attributes = attributes, + downloadLimitLimit = fileDownloadLimit?.limit, + downloadLimitCount = fileDownloadLimit?.count +) 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 517f40263400..1106e6239d5c 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -38,6 +38,7 @@ import com.nextcloud.client.database.dao.FileDao; import com.nextcloud.client.database.dao.OfflineOperationDao; import com.nextcloud.client.database.dao.RecommendedFileDao; +import com.nextcloud.client.database.dao.ShareDao; import com.nextcloud.client.database.entity.FileEntity; import com.nextcloud.client.database.entity.OfflineOperationEntity; import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository; @@ -88,7 +89,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import kotlin.Pair; @@ -112,6 +112,8 @@ public class FileDataStorageManager { public final RecommendedFileDao recommendedFileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).recommendedFileDao(); public final OfflineOperationDao offlineOperationDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).offlineOperationDao(); public final FileDao fileDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).fileDao(); + public final ShareDao shareDao = NextcloudDatabase.getInstance(MainApp.getAppContext()).shareDao(); + private final Gson gson = new Gson(); public final OfflineOperationsRepositoryType offlineOperationsRepository; private final static int DEFAULT_CURSOR_INT_VALUE = -1; @@ -1651,25 +1653,6 @@ private int getIntOrDefault(Cursor cursor, String columnName) { return cursor.getInt(index); } - private void resetShareFlagsInFolder(OCFile folder) { - ContentValues contentValues = new ContentValues(); - contentValues.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, Boolean.FALSE); - contentValues.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, Boolean.FALSE); - String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PARENT + " = ?"; - String[] whereArgs = new String[]{user.getAccountName(), String.valueOf(folder.getFileId())}; - - if (getContentResolver() != null) { - getContentResolver().update(ProviderTableMeta.CONTENT_URI, contentValues, where, whereArgs); - - } else { - try { - getContentProviderClient().update(ProviderTableMeta.CONTENT_URI, contentValues, where, whereArgs); - } catch (RemoteException e) { - Log_OC.e(TAG, "Exception in resetShareFlagsInFiles" + e.getMessage(), e); - } - } - } - private void resetShareFlagInAFile(String filePath) { ContentValues contentValues = new ContentValues(); contentValues.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, Boolean.FALSE); @@ -1689,67 +1672,6 @@ private void resetShareFlagInAFile(String filePath) { } } - @VisibleForTesting - public void cleanShares() { - String where = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?"; - String[] whereArgs = new String[]{user.getAccountName()}; - - if (getContentResolver() != null) { - getContentResolver().delete(ProviderTableMeta.CONTENT_URI_SHARE, where, whereArgs); - - } else { - try { - getContentProviderClient().delete(ProviderTableMeta.CONTENT_URI_SHARE, where, whereArgs); - } catch (RemoteException e) { - Log_OC.e(TAG, "Exception in cleanShares" + e.getMessage(), e); - } - } - } - - // TODO shares null? - public void saveShares(List shares) { - cleanShares(); - ArrayList operations = new ArrayList<>(shares.size()); - - // prepare operations to insert or update files to save in the given folder - for (OCShare share : shares) { - ContentValues contentValues = createContentValueForShare(share); - - if (shareExistsForRemoteId(share.getRemoteId())) { - // updating an existing file - operations.add( - ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI_SHARE) - .withValues(contentValues) - .withSelection(ProviderTableMeta.OCSHARES_ID_REMOTE_SHARED + " = ?", - new String[]{String.valueOf(share.getRemoteId())}) - .build()); - } else { - // adding a new file - operations.add( - ContentProviderOperation.newInsert(ProviderTableMeta.CONTENT_URI_SHARE) - .withValues(contentValues) - .build() - ); - } - } - - // 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); - } else { - getContentProviderClient().applyBatch(operations); - } - - } catch (OperationApplicationException | RemoteException e) { - Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); - } - } - } - public void removeShare(OCShare share) { Uri contentUriShare = ProviderTableMeta.CONTENT_URI_SHARE; String where = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND + @@ -1887,33 +1809,6 @@ public void removeSharesForFile(String remotePath) { } } - // TOOD check if shares can be null - public void saveSharesInFolder(ArrayList shares, OCFile folder) { - resetShareFlagsInFolder(folder); - ArrayList operations = new ArrayList<>(); - operations = prepareRemoveSharesInFolder(folder, operations); - - // prepare operations to insert or update files to save in the given folder - operations = prepareInsertShares(shares, operations); - - // 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); - - } else { - - getContentProviderClient().applyBatch(operations); - } - - } catch (OperationApplicationException | RemoteException e) { - Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); - } - } - } - /** * Prepare operations to insert or update files to save in the given folder * @@ -1938,27 +1833,6 @@ private ArrayList prepareInsertShares(Iterable prepareRemoveSharesInFolder( - OCFile folder, ArrayList preparedOperations) { - if (folder != null) { - String where = ProviderTableMeta.OCSHARES_PATH + AND - + ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + "=?"; - String[] whereArgs = new String[]{"", user.getAccountName()}; - - List files = getFolderContent(folder, false); - - for (OCFile file : files) { - whereArgs[0] = file.getRemotePath(); - preparedOperations.add( - ContentProviderOperation.newDelete(ProviderTableMeta.CONTENT_URI_SHARE). - withSelection(where, whereArgs). - build() - ); - } - } - return preparedOperations; - } - private ArrayList prepareRemoveSharesInFile( String filePath, ArrayList preparedOperations) { diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt b/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt new file mode 100644 index 000000000000..66622510612f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFileListAdapterDataProviderImpl.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider + +class OCFileListAdapterDataProviderImpl(private val storageManager: FileDataStorageManager) : + OCFileListAdapterDataProvider { + override fun convertToOCFiles(id: Long): List = + storageManager.offlineOperationsRepository.convertToOCFiles(id) + + override suspend fun getFolderContent(id: Long): List = + storageManager.fileDao.getFolderContentSuspended(id) + + override fun createFileInstance(entity: FileEntity): OCFile = storageManager.createFileInstance(entity) +} 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 9aa3702d5b12..579cd3120f68 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 @@ -10,18 +10,14 @@ package com.owncloud.android.ui.adapter; import android.accounts.AccountManager; -import android.accounts.AuthenticatorException; -import android.accounts.OperationCanceledException; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ComponentCallbacks; -import android.content.ContentValues; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Color; import android.graphics.drawable.Drawable; -import android.os.Build; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; @@ -38,7 +34,6 @@ import com.nextcloud.client.database.entity.OfflineOperationEntity; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.preferences.AppPreferences; -import com.nextcloud.model.OCFileFilterType; import com.nextcloud.model.OfflineOperationType; import com.nextcloud.utils.LinkHelper; import com.nextcloud.utils.extensions.OCFileExtensionsKt; @@ -52,27 +47,20 @@ import com.owncloud.android.databinding.ListItemBinding; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.OCFile; +import com.owncloud.android.datamodel.OCFileListAdapterDataProviderImpl; import com.owncloud.android.datamodel.SyncedFolderProvider; import com.owncloud.android.datamodel.ThumbnailsCacheManager; import com.owncloud.android.datamodel.VirtualFolderType; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; -import com.owncloud.android.db.ProviderMeta; -import com.owncloud.android.lib.common.OwnCloudClientFactory; import com.owncloud.android.lib.common.accounts.AccountUtils; -import com.owncloud.android.lib.common.operations.RemoteOperation; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.files.model.RemoteFile; -import com.owncloud.android.lib.resources.shares.OCShare; import com.owncloud.android.lib.resources.shares.ShareType; import com.owncloud.android.lib.resources.shares.ShareeUser; import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.tags.Tag; -import com.owncloud.android.operations.RefreshFolderOperation; -import com.owncloud.android.operations.RemoteOperationFailedException; 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.OCFileListAdapterDataProvider; +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterHelper; import com.owncloud.android.ui.fragment.OCFileListFragment; import com.owncloud.android.ui.fragment.SearchType; import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; @@ -85,17 +73,14 @@ import com.owncloud.android.utils.theme.ViewThemeUtils; import java.io.File; -import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -103,6 +88,7 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import kotlin.Pair; +import kotlin.Unit; import me.zhanghai.android.fastscroll.PopupTextProvider; /** @@ -123,6 +109,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter listOfHiddenFiles = new ArrayList<>(); private FileDataStorageManager mStorageManager; + private OCFileListAdapterDataProvider adapterDataProvider; private User user; private final OCFileListFragmentInterface ocFileListFragmentInterface; private final boolean isRTL; @@ -148,6 +135,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter recommendedFiles = new ArrayList<>(); private RecommendedFilesAdapter recommendedFilesAdapter; + private final OCFileListAdapterHelper helper = new OCFileListAdapterHelper(); public OCFileListAdapter( Activity activity, @@ -176,6 +164,8 @@ public OCFileListAdapter( mStorageManager = new FileDataStorageManager(user, activity.getContentResolver()); } + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); + userId = AccountManager .get(activity) .getUserData(this.user.toPlatformAccount(), @@ -546,33 +536,6 @@ private void checkVisibilityOfFileFeaturesLayout(ListViewHolder holder) { fileFeaturesLayout.setVisibility(fileFeaturesVisibility); } - private void mergeOCFilesForLivePhoto() { - List filesToRemove = new ArrayList<>(); - - for (int i = 0; i < mFiles.size(); i++) { - OCFile file = mFiles.get(i); - - for (int j = i + 1; j < mFiles.size(); j++) { - OCFile nextFile = mFiles.get(j); - String fileLocalId = String.valueOf(file.getLocalId()); - String nextFileLinkedLocalId = nextFile.getLinkedFileIdForLivePhoto(); - - if (fileLocalId.equals(nextFileLinkedLocalId)) { - if (MimeTypeUtil.isVideo(file.getMimeType())) { - nextFile.livePhotoVideo = file; - filesToRemove.add(file); - } else if (MimeTypeUtil.isVideo(nextFile.getMimeType())) { - file.livePhotoVideo = nextFile; - filesToRemove.add(nextFile); - } - } - } - } - - mFiles.removeAll(filesToRemove); - filesToRemove.clear(); - } - private void updateLivePhotoIndicators(ListViewHolder holder, OCFile file) { boolean isLivePhoto = file.getLinkedFileIdForLivePhoto() != null; @@ -887,271 +850,91 @@ public void swapDirectory( @NonNull FileDataStorageManager updatedStorageManager, boolean onlyOnDevice, @NonNull String limitToMimeType) { + this.onlyOnDevice = onlyOnDevice; if (!updatedStorageManager.equals(mStorageManager)) { mStorageManager = updatedStorageManager; + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); ocFileListDelegate.setShowShareAvatar(true); this.user = account; } - if (mStorageManager != null) { - // TODO refactor filtering mechanism for mFiles - mFiles = mStorageManager.getFolderContent(directory, onlyOnDevice); - if (!preferences.isShowHiddenFilesEnabled()) { - mFiles = OCFileExtensionsKt.filterHiddenFiles(mFiles); - } - if (!limitToMimeType.isEmpty()) { - mFiles = OCFileExtensionsKt.filterByMimeType(mFiles, limitToMimeType); - } - if (OCFile.ROOT_PATH.equals(directory.getRemotePath()) && MainApp.isOnlyPersonFiles()) { - mFiles = OCFileExtensionsKt.limitToPersonalFiles(mFiles, userId); - } - - // TODO refactor add DrawerState instead of using static menuItemId - if (DrawerActivity.menuItemId == R.id.nav_shared && currentDirectory != null) { - mFiles = updatedStorageManager.filter(currentDirectory, OCFileFilterType.Shared); - } - if (DrawerActivity.menuItemId == R.id.nav_favorites && currentDirectory != null) { - mFiles = updatedStorageManager.filter(currentDirectory, OCFileFilterType.Favorite); - } - - // Filter out temp files from the list to prevent duplication - mFiles = OCFileExtensionsKt.filterTempFilter(mFiles); - - mFiles = OCFileExtensionsKt.filterFilenames(mFiles); - - sortOrder = preferences.getSortOrderByFolder(directory); - boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); - boolean favoritesFirst = preferences.isSortFavoritesFirst(); - mFiles = sortOrder.sortCloudFiles(mFiles, foldersBeforeFiles, favoritesFirst); - prepareListOfHiddenFiles(); - mergeOCFilesForLivePhoto(); - mFilesAll.clear(); - addOfflineOperations(directory.getFileId()); - - mFilesAll.addAll(mFiles); - currentDirectory = directory; - } else { - mFiles.clear(); - mFilesAll.clear(); - } - - searchType = null; - activity.runOnUiThread(this::notifyDataSetChanged); - } - - /** - * Converts Offline Operations to OCFiles and adds them to the adapter for visual feedback. - * This function creates pending OCFiles, but they may not consistently appear in the UI. - * The issue arises when {@link RefreshFolderOperation} deletes pending Offline Operations, while some may still exist in the table. - * If only this function is used, it cause crash in {@link FileDisplayActivity mSyncBroadcastReceiver.onReceive}. - *

- * These function also need to be used: {@link FileDataStorageManager#createPendingDirectory(String, long, long)}, {@link FileDataStorageManager#createPendingFile(String, String, long, long)}. - */ - private void addOfflineOperations(long fileId) { - List offlineOperations = mStorageManager.offlineOperationsRepository.convertToOCFiles(fileId); - if (offlineOperations.isEmpty()) { + if (mStorageManager == null) { + updateAdapter(new ArrayList<>(), null); return; } - List newFiles; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - newFiles = offlineOperations.stream() - .filter(offlineFile -> mFilesAll.stream() - .noneMatch(file -> Objects.equals(file.getDecryptedRemotePath(), offlineFile.getDecryptedRemotePath()))) - .toList(); - } else { - newFiles = offlineOperations.stream() - .filter(offlineFile -> mFilesAll.stream() - .noneMatch(file -> Objects.equals(file.getDecryptedRemotePath(), offlineFile.getDecryptedRemotePath()))) - .collect(Collectors.toList()); - } - - mFilesAll.addAll(newFiles); + helper.prepareFileList(directory, + adapterDataProvider, + onlyOnDevice, + limitToMimeType, + preferences, + userId, + (newList, fileSortOrder) -> + { + updateAdapter((List) newList, directory); + return Unit.INSTANCE; + }); } - public void setData(List objects, - SearchType searchType, - FileDataStorageManager storageManager, - @Nullable OCFile folder, - boolean clear) { - if (storageManager != null && mStorageManager == null) { - mStorageManager = storageManager; - ocFileListDelegate.setShowShareAvatar(true); - } - - - if (mStorageManager == null) { - mStorageManager = new FileDataStorageManager(user, activity.getContentResolver()); - } - - if (clear) { - mFiles.clear(); - preferences.setPhotoSearchTimestamp(0); + public void updateAdapter(List newFiles, OCFile directory) { + boolean hasSameContent = OCFileExtensionsKt.hasSameContentAs(mFiles, newFiles); - VirtualFolderType type = switch (searchType) { - case FAVORITE_SEARCH -> VirtualFolderType.FAVORITE; - case GALLERY_SEARCH -> VirtualFolderType.GALLERY; - default -> VirtualFolderType.NONE; - }; - - if (type != VirtualFolderType.GALLERY) { - mStorageManager.deleteVirtuals(type); - } - } - - // early exit - if (!objects.isEmpty() && mStorageManager != null) { - if (searchType == SearchType.SHARED_FILTER) { - parseShares(objects); - } else { - if (searchType != SearchType.GALLERY_SEARCH) { - parseVirtuals(objects, searchType); - } - } - } - - if (searchType == SearchType.GALLERY_SEARCH || - searchType == SearchType.RECENTLY_MODIFIED_SEARCH) { - mFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(mFiles); - } else if (searchType != SearchType.SHARED_FILTER) { - boolean foldersBeforeFiles = preferences.isSortFoldersBeforeFiles(); - boolean favoritesFirst = preferences.isSortFavoritesFirst(); - - if (searchType == SearchType.FAVORITE_SEARCH) { - sortOrder = preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView); - } else { - sortOrder = preferences.getSortOrderByFolder(folder); - } - - mFiles = sortOrder.sortCloudFiles(mFiles, foldersBeforeFiles, favoritesFirst); + if (hasSameContent) { + Log_OC.d(TAG, "same data passed skipping update"); + return; } + Log_OC.d(TAG, "updating the adapter"); + mFiles = new ArrayList<>(newFiles); mFilesAll.clear(); mFilesAll.addAll(mFiles); - new Handler(Looper.getMainLooper()).post(this::notifyDataSetChanged); - } - - private void parseShares(List objects) { - List shares = new ArrayList<>(); - - for (Object shareObject : objects) { - // check type before cast as of long running data fetch it is possible that old result is filled - if (shareObject instanceof OCShare ocShare) { - shares.add(ocShare); - } + if (directory != null) { + currentDirectory = directory; } - // create partial OCFile from OCShares - List files = OCShareToOCFileConverter.buildOCFilesFromShares(shares); + searchType = null; - // set localPath of individual files iff present on device - for (OCFile file : files) { - FileStorageUtils.searchForLocalFileInDefaultPath(file, user.getAccountName()); - } + activity.runOnUiThread(this::notifyDataSetChanged); + } - mFiles.clear(); - mFiles.addAll(files); - mStorageManager.saveShares(shares); + public void prepareForSearchData(FileDataStorageManager storageManager, SearchType searchType) { + initStorageManagerShowShareAvatar(storageManager); + clearSearchData(searchType); } - private void parseVirtuals(List objects, SearchType searchType) { - VirtualFolderType type; - boolean onlyMedia = false; + private void initStorageManagerShowShareAvatar(FileDataStorageManager storageManager) { + if (mStorageManager == null) { + mStorageManager = (storageManager != null) + ? storageManager + : new FileDataStorageManager(user, activity.getContentResolver()); - switch (searchType) { - case FAVORITE_SEARCH: - type = VirtualFolderType.FAVORITE; - break; - case GALLERY_SEARCH: - type = VirtualFolderType.GALLERY; - onlyMedia = true; - break; - default: - type = VirtualFolderType.NONE; - break; + if (storageManager != null) { + adapterDataProvider = new OCFileListAdapterDataProviderImpl(mStorageManager); + ocFileListDelegate.setShowShareAvatar(true); + } } + } - List contentValues = new ArrayList<>(); - - for (Object remoteFile : objects) { - OCFile ocFile = FileStorageUtils.fillOCFile((RemoteFile) remoteFile); - FileStorageUtils.searchForLocalFileInDefaultPath(ocFile, user.getAccountName()); - - try { - ocFile = mStorageManager.saveFileWithParent(ocFile, activity); - - OCFile parentFolder = mStorageManager.getFileById(ocFile.getParentId()); - if (parentFolder != null && (ocFile.isEncrypted() || parentFolder.isEncrypted())) { - Object object = RefreshFolderOperation.getDecryptedFolderMetadata( - true, - parentFolder, - OwnCloudClientFactory.createOwnCloudClient(user.toPlatformAccount(), activity), - user, - activity); - - if (object == null) { - throw new IllegalStateException("metadata is null!"); - } - - if (object instanceof DecryptedFolderMetadataFileV1) { - // update ocFile - RefreshFolderOperation.updateFileNameForEncryptedFileV1(mStorageManager, - (DecryptedFolderMetadataFileV1) object, - ocFile); - } else { - // update ocFile - RefreshFolderOperation.updateFileNameForEncryptedFile(mStorageManager, - (DecryptedFolderMetadataFile) object, - ocFile); - } - - ocFile = mStorageManager.saveFileWithParent(ocFile, activity); - } - - if (SearchType.GALLERY_SEARCH != searchType) { - // also sync folder content - if (ocFile.isFolder()) { - long currentSyncTime = System.currentTimeMillis(); - RemoteOperation refreshFolderOperation = new RefreshFolderOperation(ocFile, - currentSyncTime, - true, - false, - mStorageManager, - user, - activity); - refreshFolderOperation.execute(user, activity); - } - } + private void clearSearchData(SearchType searchType) { + preferences.setPhotoSearchTimestamp(0); - if (!onlyMedia || MimeTypeUtil.isImage(ocFile) || MimeTypeUtil.isVideo(ocFile)) { - //handling duplicates for favorites section - if (mFiles.isEmpty() || !mFiles.contains(ocFile)) { - mFiles.add(ocFile); - } - } + VirtualFolderType type = switch (searchType) { + case FAVORITE_SEARCH -> VirtualFolderType.FAVORITE; + case GALLERY_SEARCH -> VirtualFolderType.GALLERY; + default -> VirtualFolderType.NONE; + }; - ContentValues cv = new ContentValues(); - cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, type.toString()); - cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile.getFileId()); - - contentValues.add(cv); - } catch ( - RemoteOperationFailedException | - OperationCanceledException | - AuthenticatorException | - IOException | - AccountUtils.AccountNotFoundException | - IllegalStateException e) { - Log_OC.e(TAG, "Error saving file with parent" + e.getMessage(), e); - } + if (type != VirtualFolderType.GALLERY) { + mStorageManager.deleteVirtuals(type); } + } - preferences.setPhotoSearchTimestamp(System.currentTimeMillis()); - mStorageManager.saveVirtuals(contentValues); + public void setSortOrder(FileSortOrder newSortOrder) { + sortOrder = newSortOrder; } @SuppressLint("NotifyDataSetChanged") @@ -1190,14 +973,10 @@ public List getFiles() { return mFiles; } - private void prepareListOfHiddenFiles() { - listOfHiddenFiles.clear(); - - mFiles.forEach(file -> { - if (file.shouldHide()) { - listOfHiddenFiles.add(file.getFileName()); - } - }); + public void addVirtualFile(@NonNull OCFile file) { + if (mFiles.isEmpty() || !mFiles.contains(file)) { + mFiles.add(file); + } } @Override @@ -1294,5 +1073,6 @@ public void setCurrentDirectory(OCFile folder) { public void cleanup() { ocFileListDelegate.cleanup(); + helper.cleanup(); } } 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 affc513870b7..4a3bfa8a0b40 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 @@ -1,16 +1,23 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.owncloud.android.ui.adapter +import com.nextcloud.utils.extensions.saveShares +import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.shares.OCShare import com.owncloud.android.lib.resources.shares.ShareType import com.owncloud.android.lib.resources.shares.ShareeUser +import com.owncloud.android.utils.FileStorageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File object OCShareToOCFileConverter { private const val MILLIS_PER_SECOND = 1000 @@ -26,14 +33,58 @@ object OCShareToOCFileConverter { * * Note: This works only for files shared *by* the user, not files shared *with* the user. */ - @JvmStatic - fun buildOCFilesFromShares(shares: List): List { - val groupedByPath: Map> = shares - .filter { it.path != null } - .groupBy { it.path!! } - return groupedByPath - .map { (path: String, shares: List) -> buildOcFile(path, shares) } - .sortedByDescending { it.firstShareTimestamp } + fun buildOCFilesFromShares(shares: List): List = shares + .filter { !it.path.isNullOrEmpty() } + .groupBy { it.path!! } + .filterKeys { path -> + path.isNotEmpty() && !path.startsWith(OCFile.PATH_SEPARATOR) + } + .map { (path, sharesForPath) -> + buildOcFile(path, sharesForPath) + } + .sortedByDescending { it.firstShareTimestamp } + + suspend fun parseAndSaveShares( + cachedFiles: List, + data: List, + storageManager: FileDataStorageManager?, + accountName: String + ): List = withContext(Dispatchers.IO) { + if (data.isEmpty()) { + return@withContext emptyList() + } + + val shares = data.filterIsInstance() + if (shares.isEmpty()) { + return@withContext emptyList() + } + + val newShares = shares.filter { share -> + cachedFiles.none { file -> file.decryptedRemotePath == share.path } + } + + if (newShares.isEmpty()) { + return@withContext cachedFiles + } + + val files = buildOCFilesFromShares(newShares) + val baseSavePath = FileStorageUtils.getSavePath(accountName) + + val newFiles = files.map { file -> + if (!file.isFolder && (file.storagePath == null || !File(file.storagePath).exists())) { + val fullPath = baseSavePath + file.decryptedRemotePath + val candidate = File(fullPath) + if (candidate.exists()) { + file.storagePath = candidate.absolutePath + file.lastSyncDateForData = candidate.lastModified() + } + } + storageManager?.saveFile(file) + file + } + + storageManager?.saveShares(newShares, accountName) + cachedFiles + newFiles } private fun buildOcFile(path: String, shares: List): OCFile { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterDataProvider.kt b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterDataProvider.kt new file mode 100644 index 000000000000..191b7ce666e3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterDataProvider.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.helper + +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.datamodel.OCFile + +interface OCFileListAdapterDataProvider { + fun convertToOCFiles(id: Long): List + suspend fun getFolderContent(id: Long): List + fun createFileInstance(entity: FileEntity): OCFile +} 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 new file mode 100644 index 000000000000..9d1fd774e87f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/helper/OCFileListAdapterHelper.kt @@ -0,0 +1,199 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.helper + +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.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.DrawerActivity +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.ArrayList + +class OCFileListAdapterHelper { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var job: Job? = null + + @Suppress("LongParameterList") + fun prepareFileList( + directory: OCFile, + dataProvider: OCFileListAdapterDataProvider, + onlyOnDevice: Boolean, + limitToMimeType: String, + preferences: AppPreferences, + userId: String, + onComplete: (List, FileSortOrder) -> Unit + ) { + job = scope.launch { + val (sortedList, sortOrder) = prepareFileList( + directory, + dataProvider, + onlyOnDevice, + limitToMimeType, + preferences, + userId + ) + withContext(Dispatchers.Main) { + onComplete(sortedList, sortOrder) + } + } + } + + suspend fun prepareFileList( + directory: OCFile, + dataProvider: OCFileListAdapterDataProvider, + onlyOnDevice: Boolean, + limitToMimeType: String, + preferences: AppPreferences, + userId: String + ): Pair, FileSortOrder> { + val showHiddenFiles = preferences.isShowHiddenFilesEnabled() + val hasMimeTypeFilter = limitToMimeType.isNotEmpty() + val isRootAndPersonalOnly = (OCFile.ROOT_PATH == directory.remotePath && MainApp.isOnlyPersonFiles()) + val isSharedView = (DrawerActivity.menuItemId == R.id.nav_shared) + val isFavoritesView = (DrawerActivity.menuItemId == R.id.nav_favorites) + + val rawResult = getFolderContent(directory, dataProvider, onlyOnDevice) + val filtered = ArrayList(rawResult.size) + + for (file in rawResult) { + if (!showHiddenFiles && file.isHidden) { + continue + } + + if (hasMimeTypeFilter && !(file.isFolder || file.mimeType.startsWith(limitToMimeType))) { + continue + } + + if (isRootAndPersonalOnly) { + val isPersonal = file.ownerId?.let { ownerId -> + ownerId == userId && !file.isSharedWithMe && !file.mounted() + } == true + + if (!isPersonal) { + continue + } + } + + if (isSharedView && !file.isShared) { + continue + } + + if (isFavoritesView && !file.isFavorite) { + continue + } + + if (file.isTempFile()) { + continue + } + + filtered.add(file) + } + + val afterFilenameFilter = filtered.filterFilenames() + val merged = mergeOCFilesForLivePhoto(afterFilenameFilter) + val finalList = addOfflineOperations(merged, directory.fileId, dataProvider) + return sortData(directory, finalList, preferences) + } + + private fun addOfflineOperations( + files: List, + fileId: Long, + dataProvider: OCFileListAdapterDataProvider + ): List { + val offlineOperations = dataProvider.convertToOCFiles(fileId) + if (offlineOperations.isEmpty()) return files + + val newFiles = offlineOperations.filter { offlineFile -> + files.none { it.decryptedRemotePath == offlineFile.decryptedRemotePath } + } + + return files + newFiles + } + + @Suppress("NestedBlockDepth") + private fun mergeOCFilesForLivePhoto(files: List): List { + val filesToRemove = mutableSetOf() + + for (i in files.indices) { + val file = files[i] + + for (j in i + 1 until files.size) { + val nextFile = files[j] + val fileLocalId = file.localId.toString() + val nextFileLinkedLocalId = nextFile.linkedFileIdForLivePhoto + + if (fileLocalId == nextFileLinkedLocalId) { + when { + MimeTypeUtil.isVideo(file.mimeType) -> { + nextFile.livePhotoVideo = file + filesToRemove.add(file) + } + + MimeTypeUtil.isVideo(nextFile.mimeType) -> { + file.livePhotoVideo = nextFile + filesToRemove.add(nextFile) + } + } + } + } + } + + return files.filter { it !in filesToRemove } + } + + private suspend fun sortData( + directory: OCFile, + files: List, + preferences: AppPreferences + ): Pair, FileSortOrder> = withContext( + Dispatchers.IO + ) { + val sortOrder = preferences.getSortOrderByFolder(directory) + val foldersBeforeFiles: Boolean = preferences.isSortFoldersBeforeFiles() + val favoritesFirst: Boolean = preferences.isSortFavoritesFirst() + return@withContext sortOrder.sortCloudFiles(files.toMutableList(), foldersBeforeFiles, favoritesFirst) + .toList() to sortOrder + } + + private suspend fun getFolderContent( + ocFile: OCFile, + dataProvider: OCFileListAdapterDataProvider, + onlyOnDevice: Boolean + ): List = withContext(Dispatchers.IO) { + if (!ocFile.isFolder || !ocFile.fileExists()) { + return@withContext emptyList() + } + + val fileEntities: List = dataProvider.getFolderContent(ocFile.fileId) + + return@withContext fileEntities.mapNotNull { fileEntity -> + val file = dataProvider.createFileInstance(fileEntity) + if (!onlyOnDevice || file.existsOnDevice()) { + file + } else { + null + } + } + } + + fun cleanup() { + job?.cancel() + job = null + } +} 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 524594e90ee8..196f66c96577 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 @@ -1915,13 +1915,6 @@ protected void handleSearchEvent(SearchEvent event) { prepareCurrentSearch(event); searchFragment = true; - setEmptyListMessage(EmptyListState.LOADING); - mAdapter.setData(new ArrayList<>(), - NO_SEARCH, - mContainerActivity.getStorageManager(), - mFile, - true); - setFabVisible(false); Runnable switchViewsRunnable = () -> { @@ -1937,10 +1930,8 @@ protected void handleSearchEvent(SearchEvent event) { new Handler(Looper.getMainLooper()).post(switchViewsRunnable); final User currentUser = accountManager.getUser(); - final var remoteOperation = getSearchRemoteOperation(currentUser, event); - - searchTask = new OCFileListSearchTask(mContainerActivity, this, remoteOperation, currentUser, event, SharedListFragment.TASK_TIMEOUT); + searchTask = new OCFileListSearchTask(mContainerActivity, this, remoteOperation, currentUser, event, SharedListFragment.TASK_TIMEOUT, preferences); searchTask.execute(); } 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 20d545a60bbf..ef938616dd6c 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 @@ -9,15 +9,31 @@ package com.owncloud.android.ui.fragment import android.annotation.SuppressLint +import android.app.Activity +import android.content.ContentValues import androidx.lifecycle.lifecycleScope import com.nextcloud.client.account.User +import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.lib.common.OwnCloudClientFactory import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.ui.adapter.OCShareToOCFileConverter import com.owncloud.android.ui.events.SearchEvent import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -25,7 +41,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.lang.ref.WeakReference -@Suppress("LongParameterList") +@Suppress("LongParameterList", "ReturnCount", "TooGenericExceptionCaught") @SuppressLint("NotifyDataSetChanged") class OCFileListSearchTask( containerActivity: FileFragment.ContainerActivity, @@ -33,7 +49,8 @@ class OCFileListSearchTask( private val remoteOperation: RemoteOperation>, private val currentUser: User, private val event: SearchEvent, - private val taskTimeout: Long + private val taskTimeout: Long, + private val preferences: AppPreferences ) { companion object { private const val TAG = "OCFileListSearchTask" @@ -51,44 +68,53 @@ class OCFileListSearchTask( fun execute() { Log_OC.d(TAG, "search task running, query: ${event.searchType}") val fragment = fragmentReference.get() ?: return - val context = fragment.context ?: return - job = fragment.lifecycleScope.launch { - val result: RemoteOperationResult>? = withContext(Dispatchers.IO) { - try { - withTimeoutOrNull(taskTimeout) { - remoteOperation.execute(currentUser, context) - } ?: remoteOperation.executeNextcloudClient(currentUser, context) - } catch (e: Exception) { - Log_OC.e(TAG, "exception execute: ", e) - null - } - } + job = fragment.lifecycleScope.launch(Dispatchers.IO) { + val searchType = fragment.currentSearchType - withContext(Dispatchers.Main) { - if (!fragment.isAdded || !fragment.searchFragment) { - Log_OC.e(TAG, "cannot fetch sharees fragment is not ready") - return@withContext - } + // using cached data + val filesInDb = loadCachedDbFiles(event.searchType) + val sortedFilesInDb = sortSearchData(filesInDb, searchType, null, setNewSortOrder = { + fragment.adapter.setSortOrder(it) + }) + updateAdapterData(fragment, sortedFilesInDb) - if (result?.isSuccess == true) { - if (result.resultData.isEmpty()) { + // updating cache and refreshing adapter + val result = fetchRemoteResults() + if (result?.isSuccess == true) { + if (result.resultData?.isEmpty() == true) { + withContext(Dispatchers.Main) { fragment.setEmptyListMessage(SearchType.NO_SEARCH) return@withContext } - fragment.searchEvent = event - fragment.adapter.setData( - result.resultData, - fragment.currentSearchType, + return@launch + } + + fragment.adapter.prepareForSearchData(fileDataStorageManager, fragment.currentSearchType) + + val newList = if (searchType == SearchType.SHARED_FILTER) { + OCShareToOCFileConverter.parseAndSaveShares( + sortedFilesInDb, + result.resultData ?: listOf(), fileDataStorageManager, - fragment.mFile, - true + currentUser.accountName ) - - return@withContext + } else { + parseAndSaveVirtuals(result.resultData ?: listOf(), fragment) + fragment.adapter.files } + val sortedNewList = sortSearchData(newList, searchType, null, setNewSortOrder = { + fragment.adapter.setSortOrder(it) + }) + + updateAdapterData(fragment, sortedNewList) + + return@launch + } + + withContext(Dispatchers.Main) { fragment.activity?.let { DisplayUtils.showSnackMessage(it, R.string.error_fetching_sharees) } @@ -99,4 +125,170 @@ class OCFileListSearchTask( fun cancel() = job?.cancel(null) fun isFinished(): Boolean = job?.isCompleted == true + + private suspend fun loadCachedDbFiles(searchType: SearchRemoteOperation.SearchType): List { + val storage = fileDataStorageManager ?: return emptyList() + return if (searchType == SearchRemoteOperation.SearchType.SHARED_FILTER) { + storage.fileDao + .getSharedFiles(currentUser.accountName) + } else { + storage.fileDao + .getFavoriteFiles(currentUser.accountName) + }.mapNotNull { storage.createFileInstance(it) } + } + + @Suppress("DEPRECATION") + private suspend fun fetchRemoteResults(): RemoteOperationResult>? { + val fragment = fragmentReference.get() ?: return null + val context = fragment.context ?: return null + + return try { + withTimeoutOrNull(taskTimeout) { + remoteOperation.execute(currentUser, context) + } ?: remoteOperation.executeNextcloudClient(currentUser, context) + } catch (e: Exception) { + Log_OC.e(TAG, "exception execute: ", e) + null + } + } + + private suspend fun updateAdapterData(fragment: OCFileListFragment, newList: List) = + withContext(Dispatchers.Main) { + if (!fragment.isAdded || !fragment.searchFragment) { + Log_OC.e(TAG, "cannot update adapter data, fragment is not ready") + return@withContext + } + + fragment.adapter.updateAdapter(newList, null) + } + + private suspend fun sortSearchData( + list: List, + searchType: SearchType, + folder: OCFile?, + setNewSortOrder: (FileSortOrder) -> Unit + ): List = withContext(Dispatchers.IO) { + var newList = list.toMutableList() + + if (searchType == SearchType.GALLERY_SEARCH || + searchType == SearchType.RECENTLY_MODIFIED_SEARCH + ) { + return@withContext FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(newList) + } + + if (searchType != SearchType.SHARED_FILTER) { + val foldersBeforeFiles = preferences.isSortFoldersBeforeFiles() + val favoritesFirst = preferences.isSortFavoritesFirst() + + val sortOrder = + if (searchType == SearchType.FAVORITE_SEARCH) { + preferences.getSortOrderByType(FileSortOrder.Type.favoritesListView) + } else { + preferences.getSortOrderByFolder(folder) + } + + setNewSortOrder(sortOrder) + newList = sortOrder.sortCloudFiles(newList, foldersBeforeFiles, favoritesFirst) + } + + return@withContext newList + } + + @Suppress("DEPRECATION") + private suspend fun parseAndSaveVirtuals(data: List, fragment: OCFileListFragment) = + withContext(Dispatchers.IO) { + val fileDataStorageManager = fileDataStorageManager ?: return@withContext + val activity = fragment.activity ?: return@withContext + val now = System.currentTimeMillis() + + val (virtualType, onlyMedia) = when (fragment.currentSearchType) { + SearchType.FAVORITE_SEARCH -> VirtualFolderType.FAVORITE to false + SearchType.GALLERY_SEARCH -> VirtualFolderType.GALLERY to true + else -> VirtualFolderType.NONE to false + } + + val contentValuesList = ArrayList() + + for (obj in data) { + try { + val remoteFile = obj as? RemoteFile ?: continue + var ocFile = FileStorageUtils.fillOCFile(remoteFile) + FileStorageUtils.searchForLocalFileInDefaultPath(ocFile, currentUser.accountName) + ocFile = fileDataStorageManager.saveFileWithParent(ocFile, activity) + ocFile = handleEncryptionIfNeeded(ocFile, fileDataStorageManager, activity) + + if (fragment.currentSearchType != SearchType.GALLERY_SEARCH && ocFile.isFolder) { + RefreshFolderOperation( + ocFile, + now, + true, + false, + fileDataStorageManager, + currentUser, + activity + ).execute(currentUser, activity) + } + + val isMediaAllowed = + !onlyMedia || MimeTypeUtil.isImage(ocFile) || MimeTypeUtil.isVideo(ocFile) + + if (isMediaAllowed) { + fragment.adapter.addVirtualFile(ocFile) + } + + val cv = ContentValues().apply { + put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, virtualType.toString()) + put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile.fileId) + } + contentValuesList.add(cv) + } catch (_: Exception) { + } + } + + // Save timestamp + virtual entries + preferences.setPhotoSearchTimestamp(System.currentTimeMillis()) + fileDataStorageManager.saveVirtuals(contentValuesList) + } + + @Suppress("DEPRECATION") + private fun handleEncryptionIfNeeded( + ocFile: OCFile, + fileDataStorage: FileDataStorageManager, + activity: Activity + ): OCFile { + val parent = fileDataStorage.getFileById(ocFile.parentId) + ?: return ocFile + + if (!ocFile.isEncrypted && !parent.isEncrypted) return ocFile + + val client = OwnCloudClientFactory.createOwnCloudClient( + currentUser.toPlatformAccount(), + activity + ) + + val metadata = RefreshFolderOperation.getDecryptedFolderMetadata( + true, + parent, + client, + currentUser, + activity + ) ?: throw IllegalStateException("metadata is null") + + when (metadata) { + is DecryptedFolderMetadataFileV1 -> + RefreshFolderOperation.updateFileNameForEncryptedFileV1( + fileDataStorage, + metadata, + ocFile + ) + is DecryptedFolderMetadataFile -> + RefreshFolderOperation.updateFileNameForEncryptedFile( + fileDataStorage, + metadata, + ocFile + ) + } + + return fileDataStorage.saveFileWithParent(ocFile, activity) + } } diff --git a/app/src/main/java/com/owncloud/android/utils/MimeType.java b/app/src/main/java/com/owncloud/android/utils/MimeType.java index c0e6642b8622..ed466eb0f2c0 100644 --- a/app/src/main/java/com/owncloud/android/utils/MimeType.java +++ b/app/src/main/java/com/owncloud/android/utils/MimeType.java @@ -22,6 +22,7 @@ public final class MimeType { public static final String TEXT_PLAIN = "text/plain"; public static final String FILE = "application/octet-stream"; public static final String PDF = "application/pdf"; + public static final String MP4 = "video/mp4"; private MimeType() { // No instance diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/MockOCFileListAdapterDataProvider.kt b/app/src/test/java/com/owncloud/android/ui/adapter/MockOCFileListAdapterDataProvider.kt new file mode 100644 index 000000000000..c2b71334a569 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/adapter/MockOCFileListAdapterDataProvider.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterDataProvider +import com.owncloud.android.utils.MimeType + +@Suppress("LongParameterList", "MagicNumber") +class MockOCFileListAdapterDataProvider : OCFileListAdapterDataProvider { + + private var offlineOCFile: OCFile? = null + private var files = listOf() + + private fun getEntities(): List = files.map { file -> + FileEntity( + id = file.fileId, + name = file.fileName, + path = file.remotePath ?: file.fileName, + pathDecrypted = file.remotePath ?: file.fileName, + contentType = file.mimeType ?: MimeType.FILE, + accountOwner = file.ownerId ?: "unknown", + favorite = if (file.isFavorite) 1 else 0, + hidden = if (file.isHidden) 1 else 0, + sharedViaLink = if (file.isSharedViaLink) 1 else 0, + encryptedName = null, + parent = file.parentId, + creation = 0L, + modified = 0L, + contentLength = 0L, + storagePath = file.storagePath, + lastSyncDate = 0L, + lastSyncDateForData = 0L, + modifiedAtLastSyncForData = 0L, + etag = file.etag, + etagOnServer = null, + permissions = null, + remoteId = file.remoteId, + localId = file.localId, + updateThumbnail = 0, + isDownloading = 0, + isEncrypted = 0, + etagInConflict = null, + sharedWithSharee = 0, + mountType = 0, + hasPreview = 0, + unreadCommentsCount = 0, + ownerId = file.ownerId ?: "unknown", + ownerDisplayName = null, + note = null, + sharees = null, + richWorkspace = null, + metadataSize = null, + metadataLivePhoto = null, + locked = 0, + lockType = 0, + lockOwner = null, + lockOwnerDisplayName = null, + lockOwnerEditor = null, + lockTimestamp = 0L, + lockTimeout = 0, + lockToken = null, + tags = null, + metadataGPS = null, + e2eCounter = 0L, + internalTwoWaySync = 0L, + internalTwoWaySyncResult = null, + uploaded = 0L + ) + } + + fun setEntities(files: List) { + this.files = files + } + + fun setOfflineFile(file: OCFile) { + offlineOCFile = file + } + + override fun convertToOCFiles(id: Long): List = if (offlineOCFile != null) { + listOf(offlineOCFile!!) + } else { + listOf() + } + + override suspend fun getFolderContent(id: Long): List = getEntities().filter { + it.parent == id && it.path != OCFile.ROOT_PATH + } + + override fun createFileInstance(entity: FileEntity): OCFile = files.first { it.fileId == entity.id } +} diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/OCFileListAdapterHelperTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/OCFileListAdapterHelperTest.kt new file mode 100644 index 000000000000..65db28cfe60a --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/adapter/OCFileListAdapterHelperTest.kt @@ -0,0 +1,216 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter + +import android.content.Context +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.adapter.helper.OCFileListAdapterHelper +import com.owncloud.android.utils.FileSortOrder +import com.owncloud.android.utils.MimeType +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@Suppress("LongMethod", "LongParameterList") +class OCFileListAdapterHelperTest { + + private val context = mockk(relaxed = true) + private val helper = OCFileListAdapterHelper() + + private val preferences = mockk(relaxed = true) + private val dataProvider = MockOCFileListAdapterDataProvider() + + private val userId = "user123" + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + mockkStatic(MainApp::class) + every { MainApp.getAppContext() } returns context + every { MainApp.isOnlyPersonFiles() } returns false + } + + private inner class Sut { + val root = directory("/", id = 0) + + fun directory(path: String, id: Long) = OCFile(path).apply { + setFolder() + fileId = id + parentId = 0 + ownerId = userId + remoteId = id.toString() + remotePath = path + mimeType = MimeType.DIRECTORY + storagePath = "" + etag = "etag_$id" + } + + fun file( + parent: OCFile, + name: String, + id: Long, + mime: String = MimeType.FILE, + hidden: Boolean = false, + favorite: Boolean = false, + shared: Boolean = false, + localId: Long = -1, + localPath: String = "" + ) = OCFile("/$name").apply { + parentId = parent.fileId + fileId = id + remotePath = "/$name" + ownerId = userId + mimeType = mime + isHidden = hidden + isFavorite = favorite + isSharedViaLink = shared + this.localId = localId + etag = "etag_$id" + storagePath = localPath + } + + fun prepare(files: List, offline: OCFile? = null) { + dataProvider.setEntities(files) + offline?.let { dataProvider.setOfflineFile(it) } + } + + suspend fun run(directory: OCFile, mime: String = "") = helper.prepareFileList( + directory = directory, + dataProvider = dataProvider, + onlyOnDevice = false, + limitToMimeType = mime, + preferences = preferences, + userId = userId + ) + } + + private fun stubPreferences( + showHidden: Boolean = false, + sort: FileSortOrder, + folderFirst: Boolean = true, + favFirst: Boolean = false + ) { + every { preferences.isShowHiddenFilesEnabled() } returns showHidden + every { preferences.getSortOrderByFolder(any()) } returns sort + every { preferences.isSortFoldersBeforeFiles() } returns folderFirst + every { preferences.isSortFavoritesFirst() } returns favFirst + } + + @Test + fun `prepareFileList with multiple folders and sort Z to A`() = runBlocking { + val env = Sut() + val root = env.root + + val sub1 = env.directory("/subDir", 1) + val sub2 = env.directory("/subDir2", 2) + + val fImage = env.file(root, "image.jpg", 11, MimeType.JPEG) + val fVideo = env.file(root, "video.mp4", 12, MimeType.MP4) + val fSub = env.file(sub1, "video2.mp4", 21, MimeType.MP4) + + env.prepare(listOf(root, sub1, sub2, fImage, fVideo, fSub)) + + stubPreferences(sort = FileSortOrder.SORT_Z_TO_A) + + val (list, sort) = env.run(root) + + assertEquals(listOf("subDir2", "subDir", "video.mp4", "image.jpg"), list.map { it.fileName }) + assertEquals(FileSortOrder.SORT_Z_TO_A, sort) + } + + @Test + fun `prepareFileList with multiple folders and favorites first`() = runBlocking { + val env = Sut() + val root = env.root + + val sub1 = env.directory("/subDir", 1) + val sub2 = env.directory("/subDir2", 2) + + val fImage = env.file(root, "image.jpg", 11, MimeType.JPEG) + val fVideo = env.file(root, "video.mp4", 12, MimeType.MP4) + val fFav = env.file(root, "fav_image.jpg", 19, MimeType.JPEG, favorite = true) + val fSub = env.file(sub1, "video2.mp4", 21, MimeType.MP4) + + env.prepare(listOf(root, sub1, sub2, fImage, fVideo, fFav, fSub)) + + stubPreferences(sort = FileSortOrder.SORT_A_TO_Z, favFirst = true) + + val (list, sort) = env.run(root) + + assertEquals( + listOf("fav_image.jpg", "subDir", "subDir2", "image.jpg", "video.mp4"), + list.map { it.fileName } + ) + assertEquals(FileSortOrder.SORT_A_TO_Z, sort) + } + + @Test + fun `prepareFileList with multiple folders`() = runBlocking { + val env = Sut() + val root = env.root + + val sub1 = env.directory("/subDir", 1) + val sub2 = env.directory("/subDir2", 2) + + val fImg = env.file(root, "image.jpg", 11, MimeType.JPEG) + val fVid = env.file(root, "video.mp4", 12, MimeType.MP4) + val fSubVid = env.file(sub1, "video2.mp4", 21, MimeType.MP4) + + env.prepare(listOf(root, sub1, sub2, fImg, fVid, fSubVid)) + + stubPreferences(sort = FileSortOrder.SORT_A_TO_Z) + + val (list, sort) = env.run(root) + + assertEquals(listOf("subDir", "subDir2", "image.jpg", "video.mp4"), list.map { it.fileName }) + assertEquals(FileSortOrder.SORT_A_TO_Z, sort) + } + + @Test + fun `prepareFileList hides hidden files and sorts A to Z`() = runBlocking { + val env = Sut() + val root = env.root + + val fHidden = env.file(root, ".hidden.jpg", 1, MimeType.JPEG, hidden = true) + val fImg = env.file(root, "image.jpg", 2, MimeType.JPEG) + val fVid = env.file(root, "video.mp4", 3, MimeType.MP4) + val fTemp = env.file(root, "temp.tmp", 4, MimeType.FILE) + val fOther = env.file(env.directory("/other", 202), "other.jpg", 5, MimeType.JPEG) + val fPersonal = env.file(root, "personal.jpg", 6, MimeType.JPEG) + val fShared = env.file(root, "shared.jpg", 7, MimeType.JPEG, shared = true) + val fFav = env.file(root, "favorite.jpg", 8, MimeType.JPEG, favorite = true) + val fLiveImg = env.file(root, "live.jpg", 9, MimeType.JPEG, localId = 77) + val fLiveVid = env.file(root, "live_video.mp4", 10, MimeType.MP4).apply { setLivePhoto("77") } + val offline = env.file(root, "offline.jpg", 11, MimeType.JPEG) + + env.prepare( + listOf( + root, fHidden, fImg, fVid, fTemp, fOther, fPersonal, + fShared, fFav, fLiveImg, fLiveVid + ), + offline = offline + ) + + stubPreferences(sort = FileSortOrder.SORT_A_TO_Z) + + val (list, sort) = env.run(root, mime = "image") + + assertEquals( + listOf("favorite.jpg", "image.jpg", "live.jpg", "offline.jpg", "personal.jpg", "shared.jpg"), + list.map { it.fileName } + ) + assertEquals(FileSortOrder.SORT_A_TO_Z, sort) + } +}