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 f8d5f3855c30..164df5698362 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 @@ -65,6 +65,23 @@ interface FileDao { ) suspend fun getGalleryItemsSuspended(startDate: Long, endDate: Long, fileOwner: String): List + @Query( + "SELECT * FROM filelist" + + " WHERE (content_type LIKE 'image/%' OR content_type LIKE 'video/%')" + + " AND file_owner = :fileOwner" + + " AND path LIKE :pathPrefix || '%'" + + " AND (:mimeFilter IS NULL OR content_type LIKE :mimeFilter)" + + " ORDER BY modified DESC" + + " LIMIT :limit OFFSET :offset" + ) + suspend fun getGalleryItemsPageSuspended( + fileOwner: String, + pathPrefix: String, + mimeFilter: String?, + limit: Int, + offset: Int + ): List + @Query("SELECT * FROM filelist WHERE file_owner = :fileOwner ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}") fun getAllFiles(fileOwner: String): List 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 e163c5a861a8..403736d83333 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -25,9 +25,33 @@ suspend fun FileDataStorageManager.saveShares(shares: List, accountName } } -suspend fun FileDataStorageManager.getAllGalleryItemsSuspended(): List { - val fileEntities = fileDao.getGalleryItemsSuspended(0, Long.MAX_VALUE, user.accountName) - return fileEntities.map { createFileInstance(it) } +private const val GALLERY_DB_CHUNK_SIZE = 500 + +suspend fun FileDataStorageManager.getGalleryItemsPageSuspended( + pathPrefix: String, + mimeFilter: String?, + limit: Int +): List { + val result = ArrayList(minOf(limit, GALLERY_DB_CHUNK_SIZE)) + var offset = 0 + + while (offset < limit) { + val chunkLimit = minOf(GALLERY_DB_CHUNK_SIZE, limit - offset) + val entities = fileDao.getGalleryItemsPageSuspended( + user.accountName, + pathPrefix, + mimeFilter, + chunkLimit, + offset + ) + + entities.mapTo(result) { createFileInstance(it) } + offset += entities.size + + if (entities.size < chunkLimit) break + } + + return result } fun FileDataStorageManager.searchFilesByName(file: OCFile, accountName: String, query: String): List = diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 27340b873e68..dd932f9f64ee 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -331,7 +331,7 @@ private void openFavoritesTab() { } private void openMediaTab(int menuItemId) { - GalleryFragment.Companion.setLastMediaItemPosition(null); + GalleryFragment.Companion.clearSavedScrollState(); resetOnlyPersonalAndOnDevice(); setupToolbar(); startPhotoSearch(menuItemId); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt index 72b08f8ac274..d871787a4d96 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt @@ -31,7 +31,6 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.ComponentsGetter import com.owncloud.android.ui.activity.FolderPickerActivity -import com.owncloud.android.ui.fragment.GalleryFragment import com.owncloud.android.ui.fragment.SearchType import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.utils.DisplayUtils @@ -166,7 +165,6 @@ class OCFileListDelegate( imageView.setOnClickListener { ocFileListFragmentInterface.onItemClicked(file) - GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition) } if (!hideItemOptions) { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt index a2be5cf3bb99..86d298bb69f3 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2023 Alper Ozturk * SPDX-FileCopyrightText: 2023 TSI-mc * SPDX-FileCopyrightText: 2019 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Nextcloud GmbH @@ -14,6 +15,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -28,7 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.utils.extensions.getAllGalleryItemsSuspended +import com.nextcloud.utils.extensions.getGalleryItemsPageSuspended import com.nextcloud.utils.extensions.getParcelableArgument import kotlinx.coroutines.Job import com.nextcloud.utils.extensions.getTypedActivity @@ -47,7 +49,6 @@ import com.owncloud.android.ui.adapter.GalleryAdapter import com.owncloud.android.ui.asynctasks.GallerySearchTask import com.owncloud.android.ui.events.ChangeMenuEvent import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState -import com.owncloud.android.utils.MimeTypeUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -61,6 +62,8 @@ class GalleryFragment : private var showGalleryJob: Job? = null private var endDate: Long = 0 private val limit = 150 + private var loadedItemCount = INITIAL_GALLERY_WINDOW + private var restoreScrollPending = false private var adapter: GalleryAdapter? = null private var bottomSheet: GalleryFragmentBottomSheetDialog? = null @@ -148,6 +151,8 @@ class GalleryFragment : override fun onPause() { super.onPause() photoSearchTask?.cancel() + savedScrollState = recyclerView?.layoutManager?.onSaveInstanceState() + savedLoadedItemCount = loadedItemCount } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -173,6 +178,10 @@ class GalleryFragment : updateSubtitle(bottomSheet?.currMediaState) + // Restore the previously loaded window so a saved scroll position still resolves to a valid item. + loadedItemCount = savedLoadedItemCount ?: INITIAL_GALLERY_WINDOW + restoreScrollPending = savedScrollState != null + handleSearchEvent() } @@ -198,11 +207,6 @@ class GalleryFragment : val layoutManager = GridLayoutManager(context, 1) adapter?.setLayoutManager(layoutManager) recyclerView?.setLayoutManager(layoutManager) - recyclerView?.post { - lastMediaItemPosition?.let { position -> - recyclerView?.layoutManager?.scrollToPosition(position) - } - } } override fun onConfigurationChanged(newConfig: Configuration) { @@ -308,6 +312,9 @@ class GalleryFragment : private fun searchAndDisplayAfterChangingFolder() { // TODO: Fix folder change, it seems it doesn't work at all + loadedItemCount = INITIAL_GALLERY_WINDOW + restoreScrollPending = false + clearSavedScrollState() endDate = System.currentTimeMillis() / 1000 isPhotoSearchQueryRunning = true runGallerySearchTask() @@ -367,37 +374,41 @@ class GalleryFragment : Log_OC.d(this, "Gallery swipe: retrieve items because end of gallery display") } - // Almost reached the end, continue to load new photos + // Almost reached the end, widen the display window and continue to load new photos endDate = lastItemTimestamp + loadedItemCount += GALLERY_WINDOW_INCREMENT + showAllGalleryItems() isPhotoSearchQueryRunning = true runGallerySearchTask() } } override fun updateMediaContent(mediaState: MediaState) { + loadedItemCount = INITIAL_GALLERY_WINDOW + restoreScrollPending = false + clearSavedScrollState() showAllGalleryItems() } fun showAllGalleryItems() { val mediaState = bottomSheet?.currMediaState ?: return + val mimeFilter = when (mediaState) { + MediaState.MEDIA_STATE_PHOTOS_ONLY -> IMAGE_MIME_FILTER + MediaState.MEDIA_STATE_VIDEOS_ONLY -> VIDEO_MIME_FILTER + else -> null + } + showGalleryJob?.cancel() showGalleryJob = lifecycleScope.launch(Dispatchers.Default) { val remotePath = preferences.getLastSelectedMediaFolder() - val items = mContainerActivity.storageManager.getAllGalleryItemsSuspended() - - val isPhotosOnly = mediaState == MediaState.MEDIA_STATE_PHOTOS_ONLY - val isVideosOnly = mediaState == MediaState.MEDIA_STATE_VIDEOS_ONLY + val items = mContainerActivity.storageManager.getGalleryItemsPageSuspended( + remotePath, + mimeFilter, + loadedItemCount + ) - val filteredItems = items.filter { - if (!it.remotePath.startsWith(remotePath)) return@filter false - if (isPhotosOnly) return@filter MimeTypeUtil.isImage(it.mimeType) - if (isVideosOnly) return@filter MimeTypeUtil.isVideo(it.mimeType) - true - } - - val galleryItems = - filteredItems.toGalleryItems(columnsCount, ThumbnailsCacheManager.getThumbnailDimension()) + val galleryItems = items.toGalleryItems(columnsCount, ThumbnailsCacheManager.getThumbnailDimension()) withContext(Dispatchers.Main) { if (galleryItems.isEmpty()) { @@ -405,6 +416,14 @@ class GalleryFragment : } adapter?.updateList(galleryItems) updateSubtitle(mediaState) + + if (restoreScrollPending && galleryItems.isNotEmpty()) { + restoreScrollPending = false + savedScrollState?.let { state -> + savedScrollState = null + recyclerView?.layoutManager?.onRestoreInstanceState(state) + } + } } } } @@ -448,14 +467,24 @@ class GalleryFragment : private const val MAX_ITEMS_PER_ROW = 10 private const val FRAGMENT_TAG_BOTTOM_SHEET = "data" - private var lastMediaItemPosition: Int? = null + private const val INITIAL_GALLERY_WINDOW = 500 + private const val GALLERY_WINDOW_INCREMENT = 500 + private const val IMAGE_MIME_FILTER = "image/%" + private const val VIDEO_MIME_FILTER = "video/%" + const val REFRESH_SEARCH_EVENT_RECEIVER: String = "refreshSearchEventReceiver" private const val MAX_LANDSCAPE_COLUMN_SIZE = 5 private const val MAX_PORTRAIT_COLUMN_SIZE = 2 - fun setLastMediaItemPosition(position: Int?) { - lastMediaItemPosition = position + // Kept across the activity recreation that happens when returning from the media preview, + // so the grid reopens at the same scroll position and with the same loaded window. + private var savedScrollState: Parcelable? = null + private var savedLoadedItemCount: Int? = null + + fun clearSavedScrollState() { + savedScrollState = null + savedLoadedItemCount = 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 f9b959d2ecfc..54404a203a6c 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 @@ -154,6 +154,7 @@ import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; import static com.owncloud.android.ui.fragment.SearchType.FAVORITE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.FILE_SEARCH; +import static com.owncloud.android.ui.fragment.SearchType.GALLERY_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.NO_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.RECENT_FILES_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.SHARED_FILTER; @@ -1196,7 +1197,7 @@ private void fileOnItemClick(OCFile file) { return; } - if (PreviewImageFragment.canBePreviewed(file) && mContainerActivity instanceof FileDisplayActivity fda) { + if (canPreviewInVirtualFolderPager(file) && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewImageWithSearchContext(file, searchFragment, currentSearchType); } else if (file.isDown() && mContainerActivity instanceof FileDisplayActivity fda) { fda.previewFile(file, this::setFabVisible); @@ -1205,6 +1206,20 @@ private void fileOnItemClick(OCFile file) { } } + /** + * In a gallery or favorites search the preview pager is built from the whole virtual folder, so a directly + * tapped video must open through the same pager as the images. + */ + private boolean canPreviewInVirtualFolderPager(OCFile file) { + if (PreviewImageFragment.canBePreviewed(file)) { + return true; + } + + boolean virtualFolderSearch = searchFragment + && (currentSearchType == GALLERY_SEARCH || currentSearchType == FAVORITE_SEARCH); + return virtualFolderSearch && MimeTypeUtil.isVideo(file); + } + private void handlePendingDownloadFile(OCFile file) { if (!isAccountManagerInitialized()) { Log_OC.e(TAG, "AccountManager not yet initialized");