From 77ae68c1422e952608d480c525252129ef3735dd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:05:13 +0200 Subject: [PATCH 01/19] recycle row holder and check gallery items before update Signed-off-by: alperozturk96 --- .../android/ui/adapter/GalleryAdapter.kt | 63 +++++++++++++++++-- .../android/ui/adapter/GalleryAdapterTest.kt | 13 ++-- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index e6f831ca2d6a..372a66a32c25 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -25,10 +25,13 @@ import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.nextcloud.client.account.User import com.nextcloud.utils.extensions.toGalleryItems import com.nextcloud.utils.thumbnail.ThumbnailGenerator +import com.owncloud.android.R import com.owncloud.android.databinding.GalleryHeaderBinding import com.owncloud.android.databinding.GalleryRowBinding import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.GalleryItems +import com.owncloud.android.datamodel.GalleryRow +import com.owncloud.android.datamodel.GalleryRowLayout import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.ComponentsGetter @@ -159,14 +162,17 @@ class GalleryAdapter( } else { GalleryRowHolder( GalleryRowBinding.inflate(LayoutInflater.from(parent.context), parent, false), - defaultThumbnailSize.toFloat(), ocFileListDelegate, - storageManager, this, viewThemeUtils ) } + override fun onViewRecycled(holder: SectionedViewHolder) { + super.onViewRecycled(holder) + (holder as? GalleryRowHolder)?.recycle() + } + override fun onBindViewHolder( holder: SectionedViewHolder?, section: Int, @@ -205,8 +211,48 @@ class GalleryAdapter( @SuppressLint("NotifyDataSetChanged") fun updateList(items: List) { + val previous = files files = items - notifyDataSetChanged() + + if (!hasSameSectionLayout(previous, items)) { + notifyDataSetChanged() + return + } + + items.forEachIndexed { section, galleryItems -> + galleryItems.rows.forEachIndexed { row, galleryRow -> + if (!galleryRow.contentEquals(previous[section].rows[row])) { + notifyItemChanged(getAbsolutePosition(section, row)) + } + } + } + } + + private fun hasSameSectionLayout(previous: List, current: List): Boolean { + if (previous.size != current.size) { + return false + } + + return previous.indices.all { section -> + previous[section].date == current[section].date && + previous[section].rows.size == current[section].rows.size + } + } + + private fun GalleryRow.contentEquals(other: GalleryRow): Boolean { + if (cellSizes != other.cellSizes || files.size != other.files.size) { + return false + } + + return files.indices.all { index -> + val file = files[index] + val otherFile = other.files[index] + + file.fileId == otherFile.fileId && + file.etag == otherFile.etag && + file.isFavorite == otherFile.isFavorite && + file.isUpdateThumbnailNeeded == otherFile.isUpdateThumbnailNeeded + } } @SuppressLint("NotifyDataSetChanged") @@ -300,9 +346,16 @@ class GalleryAdapter( columns = newColumn } + fun rowLayout(): GalleryRowLayout = GalleryRowLayout( + columns, + context.resources.displayMetrics.widthPixels, + context.resources.getInteger(R.integer.small_margin), + defaultThumbnailSize + ) + @SuppressLint("NotifyDataSetChanged") fun showAlbumItems(albumItems: List) { - files = albumItems.toGalleryItems(columns, defaultThumbnailSize) + files = albumItems.toGalleryItems(rowLayout()) notifyDataSetChanged() } @@ -314,7 +367,7 @@ class GalleryAdapter( val allFiles = getAllFiles() allFiles.firstOrNull { it.remotePath == remotePath }?.also { file -> file.isFavorite = favorite - files = allFiles.toGalleryItems(columns, defaultThumbnailSize) + files = allFiles.toGalleryItems(rowLayout()) notifyItemChanged(file) } } diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt index add968aeaf53..96afede94f92 100644 --- a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterTest.kt @@ -100,11 +100,11 @@ class GalleryAdapterTest { val list = listOf( GalleryItems( 1649317247, - listOf(GalleryRow(listOf(OCFile("/1.md"), OCFile("/2.md")), thumbnailSize, thumbnailSize)) + listOf(GalleryRow(listOf(OCFile("/1.md"), OCFile("/2.md")), emptyList())) ), GalleryItems( 1649317248, - listOf(GalleryRow(listOf(OCFile("/1.md"), OCFile("/2.md")), thumbnailSize, thumbnailSize)) + listOf(GalleryRow(listOf(OCFile("/1.md"), OCFile("/2.md")), emptyList())) ) ) @@ -147,8 +147,7 @@ class GalleryAdapterTest { parentId = 0 } ), - thumbnailSize, - thumbnailSize + emptyList() ) ) rows.add( @@ -163,8 +162,7 @@ class GalleryAdapterTest { parentId = 0 } ), - thumbnailSize, - thumbnailSize + emptyList() ) ) val alreadyUsedFileIds = listOf(row1File1, row1File2, row2File1, row2File2) @@ -183,8 +181,7 @@ class GalleryAdapterTest { OCFile("/$id1.md").apply { fileId = id1 }, OCFile("/$id2.md").apply { fileId = id2 } ), - thumbnailSize, - thumbnailSize + emptyList() ) ) } From 2860e283c3e77af34bf132e60ad6e2def9f33028 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:05:53 +0200 Subject: [PATCH 02/19] setItemViewCacheSize Signed-off-by: alperozturk96 --- .../android/ui/fragment/GalleryFragment.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 6d5404fd0dcc..d7b835a2a3d5 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 @@ -86,11 +86,11 @@ class GalleryFragment : } bottomSheet = GalleryFragmentBottomSheetDialog() columnsCount = ColumnCount.Wide.get(resources.isLandscape()) - registerRefreshSearchEventReceiver() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + registerRefreshSearchEventReceiver() if (!isFromAlbum) { addMenuProvider() } @@ -131,7 +131,7 @@ class GalleryFragment : private val refreshSearchEventReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - getTypedActivity(FileDisplayActivity::class.java)?.startPhotoSearch(R.id.nav_gallery) + showAllGalleryItems() } } @@ -211,7 +211,11 @@ class GalleryFragment : val layoutManager = GridLayoutManager(context, 1) adapter?.setLayoutManager(layoutManager) - recyclerView?.setLayoutManager(layoutManager) + recyclerView?.run { + setLayoutManager(layoutManager) + setItemViewCacheSize(ITEM_VIEW_CACHE_SIZE) + itemAnimator = null + } } override fun onConfigurationChanged(newConfig: Configuration) { @@ -245,7 +249,10 @@ class GalleryFragment : private fun handleSearchEvent() { prepareCurrentSearch(searchEvent) - setEmptyListMessage(EmptyListState.LOADING) + + if (adapter?.isEmpty() != false) { + setEmptyListMessage(EmptyListState.LOADING) + } // always show first stored items showAllGalleryItems() @@ -352,7 +359,7 @@ class GalleryFragment : val lastVisibleItem: Int = gridLayoutManager.findLastCompletelyVisibleItemPosition() val visibleItemCount: Int = gridLayoutManager.childCount - if (lastVisibleItem == RecyclerView.NO_POSITION) { + if (lastVisibleItem <= 0) { return } @@ -393,6 +400,7 @@ class GalleryFragment : fun showAllGalleryItems() { val mediaState = bottomSheet?.currMediaState ?: return + val rowLayout = adapter?.rowLayout() ?: return val mimeFilter = when (mediaState) { MediaState.MEDIA_STATE_PHOTOS_ONLY -> IMAGE_MIME_FILTER @@ -409,7 +417,7 @@ class GalleryFragment : loadedItemCount ) - val galleryItems = items.toGalleryItems(columnsCount, ThumbnailsCacheManager.getThumbnailDimension()) + val galleryItems = items.toGalleryItems(rowLayout) withContext(Dispatchers.Main) { if (galleryItems.isEmpty()) { @@ -477,6 +485,7 @@ class GalleryFragment : companion object { private const val MAX_ITEMS_PER_ROW = 10 private const val FRAGMENT_TAG_BOTTOM_SHEET = "data" + private const val ITEM_VIEW_CACHE_SIZE = 8 private const val INITIAL_GALLERY_WINDOW = 500 private const val GALLERY_WINDOW_INCREMENT = 500 From 93367166a46d51b3d0f43fda6a90f81a49ea9c48 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:06:29 +0200 Subject: [PATCH 03/19] do not create video overlay for each time, cached it also Signed-off-by: alperozturk96 --- .../jobs/gallery/GalleryImageGenerationJob.kt | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt index 7baaf488154b..d9216f9810a4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt @@ -8,20 +8,21 @@ package com.nextcloud.client.jobs.gallery import android.graphics.Bitmap -import android.graphics.Point import android.media.ThumbnailUtils import android.os.Build import android.provider.MediaStore import android.util.Size -import android.view.WindowManager import android.widget.ImageView import androidx.core.content.ContextCompat import com.nextcloud.client.account.User import com.nextcloud.utils.extensions.getBigThumbnail import com.nextcloud.utils.extensions.getBigThumbnailKey import com.nextcloud.utils.extensions.getSmallThumbnail +import com.nextcloud.utils.extensions.getSmallThumbnailKey import com.nextcloud.utils.extensions.isPNG import com.nextcloud.utils.extensions.toFile +import com.nextcloud.utils.extensions.videoOverlayKey +import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager @@ -98,43 +99,40 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag return } - var newImage = false - val bitmap: Bitmap? = getBitmap(file, onNewThumbnail = { newImage = true }) + val bitmap: Bitmap? = getBitmap(file) if (bitmap == null) { withContext(Dispatchers.Main) { listener.onError() } return } - setThumbnail(bitmap, file, imageView, newImage, listener) + setThumbnail(bitmap, file, imageView, listener) } catch (_: Exception) { withContext(Dispatchers.Main) { listener.onError() } } } - private suspend fun getBitmap(file: OCFile, onNewThumbnail: () -> Unit): Bitmap? = withContext(Dispatchers.IO) { + private suspend fun getBitmap(file: OCFile): Bitmap? = withContext(Dispatchers.IO) { val cached = file.getBigThumbnail() if (cached != null && !file.isUpdateThumbnailNeeded) { - return@withContext applyVideoOverlayIfNeeded(file, cached) + return@withContext withCachedVideoOverlay(file, cached, file.getBigThumbnailKey()) } if (file.isDown) { val local = decodeLocalThumbnail(file) if (local != null) { ThumbnailsCacheManager.addBitmapToCache(file.getBigThumbnailKey(), local) - onNewThumbnail() - return@withContext applyVideoOverlayIfNeeded(file, local) + return@withContext withCachedVideoOverlay(file, local, file.getBigThumbnailKey()) } } val remote = semaphore.withPermit { fetchFromServer(file) } if (remote != null) { - onNewThumbnail() - return@withContext applyVideoOverlayIfNeeded(file, remote) + return@withContext withCachedVideoOverlay(file, remote, file.getBigThumbnailKey()) } file.getSmallThumbnail()?.let { small -> - return@withContext applyVideoOverlayIfNeeded(file, small) + return@withContext withCachedVideoOverlay(file, small, file.getSmallThumbnailKey()) } null @@ -147,22 +145,21 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag } private fun createImageThumbnail(file: OCFile): Bitmap? { - val wm = MainApp.getAppContext().getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager - val p = Point() - wm.defaultDisplay.getSize(p) + val box = ThumbnailsCacheManager.getResizedImageDimension() - val pxW = p.x - val pxH = p.y - - val cacheKey = file.getBigThumbnailKey() - - var bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, pxW, pxH) ?: return null + var bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, box.x, box.y) ?: return null if (file.isPNG()) { - bitmap = ThumbnailsCacheManager.handlePNG(bitmap, pxW, pxH) + bitmap = ThumbnailsCacheManager.handlePNG(bitmap, bitmap.width, bitmap.height) } - val thumbnail = ThumbnailsCacheManager.addThumbnailToCache(cacheKey, bitmap, file.storagePath, pxW, pxH) + val thumbnail = ThumbnailsCacheManager.addThumbnailToCache( + file.getBigThumbnailKey(), + bitmap, + file.storagePath, + bitmap.width, + bitmap.height + ) file.isUpdateThumbnailNeeded = false return thumbnail @@ -196,17 +193,23 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag null } - private fun applyVideoOverlayIfNeeded(file: OCFile, bitmap: Bitmap): Bitmap = if (MimeTypeUtil.isVideo(file)) { - ThumbnailsCacheManager.addVideoOverlay(bitmap, MainApp.getAppContext()) - } else { - bitmap + private fun withCachedVideoOverlay(file: OCFile, bitmap: Bitmap, sourceKey: String): Bitmap { + if (!MimeTypeUtil.isVideo(file)) { + return bitmap + } + + val overlayKey = videoOverlayKey(sourceKey) + ThumbnailMemoryCache.get(overlayKey)?.let { return it } + + return ThumbnailsCacheManager.addVideoOverlay(bitmap, MainApp.getAppContext()).also { + ThumbnailMemoryCache.put(overlayKey, it) + } } private suspend fun setThumbnail( bitmap: Bitmap, file: OCFile, imageView: ImageView, - newImage: Boolean, listener: GalleryImageGenerationListener ) = withContext(Dispatchers.Main) { val tagId = file.fileId.toString() @@ -218,11 +221,9 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag ) } - if (newImage) listener.onNewGalleryImage() - if (imageView.isAttachedToWindow) { + imageView.scaleType = ImageView.ScaleType.CENTER_CROP imageView.setImageBitmap(bitmap) - imageView.invalidate() } } From 3a5766f3b7fe77a8e200d3cff54d119f86bde9f6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:08:10 +0200 Subject: [PATCH 04/19] recycle row and use new row data Signed-off-by: alperozturk96 --- .../gallery/GalleryImageGenerationListener.kt | 1 - .../android/datamodel/GalleryCellSize.kt | 9 + .../owncloud/android/datamodel/GalleryRow.kt | 8 +- .../android/datamodel/GalleryRowLayout.kt | 33 ++++ .../android/ui/adapter/GalleryRowHolder.kt | 161 ++++++++---------- 5 files changed, 112 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/datamodel/GalleryCellSize.kt create mode 100644 app/src/main/java/com/owncloud/android/datamodel/GalleryRowLayout.kt diff --git a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt index e7ca171a1151..cfbed4fceab4 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationListener.kt @@ -9,6 +9,5 @@ package com.nextcloud.client.jobs.gallery interface GalleryImageGenerationListener { fun onSuccess() - fun onNewGalleryImage() fun onError() } diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryCellSize.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryCellSize.kt new file mode 100644 index 000000000000..fb6bb7d6d33e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryCellSize.kt @@ -0,0 +1,9 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +data class GalleryCellSize(val width: Int, val height: Int) diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt index 6e99de382fae..d9b44bde0e71 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryRow.kt @@ -8,10 +8,4 @@ */ package com.owncloud.android.datamodel -import com.nextcloud.utils.OCFileUtils - -data class GalleryRow(val files: List, val defaultHeight: Int, val defaultWidth: Int) { - fun getMaxHeight(): Float = files.maxOfOrNull { - OCFileUtils.getImageSize(it, defaultHeight.toFloat()).second.toFloat() - } ?: 0f -} +data class GalleryRow(val files: List, val cellSizes: List) diff --git a/app/src/main/java/com/owncloud/android/datamodel/GalleryRowLayout.kt b/app/src/main/java/com/owncloud/android/datamodel/GalleryRowLayout.kt new file mode 100644 index 000000000000..5b1a921c3280 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/GalleryRowLayout.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import com.nextcloud.utils.OCFileUtils + +data class GalleryRowLayout(val columns: Int, val rowWidth: Int, val spacing: Int, val fallbackCellSize: Int) { + + fun measure(files: List): List { + if (files.isEmpty()) return emptyList() + + val availableWidth = (rowWidth - spacing * (files.size - 1)).coerceAtLeast(files.size).toFloat() + val aspectRatios = files.map { it.aspectRatio() } + val height = (availableWidth / aspectRatios.sum()).toInt().coerceAtLeast(1) + + val widths = aspectRatios.map { (height * it).toInt().coerceAtLeast(1) }.toMutableList() + val remainingPixels = availableWidth.toInt() - widths.sum() + if (remainingPixels > 0) { + widths[widths.lastIndex] += remainingPixels + } + + return widths.map { GalleryCellSize(it, height) } + } + + private fun OCFile.aspectRatio(): Float { + val (width, height) = OCFileUtils.getImageSize(this, fallbackCellSize.toFloat()) + return if (height > 0) width.toFloat() / height else 1f + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt index fead84f34822..dd3f097b4c20 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt @@ -17,30 +17,30 @@ import androidx.core.view.get import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.elyeproj.loaderviewlibrary.LoaderImageView import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.utils.OCFileUtils import com.nextcloud.utils.extensions.makeRounded import com.nextcloud.utils.extensions.setVisibleIf import com.owncloud.android.R import com.owncloud.android.databinding.GalleryRowBinding -import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.GalleryCellSize import com.owncloud.android.datamodel.GalleryRow import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.theme.ViewThemeUtils -@Suppress("LongParameterList") +private const val CHECKED_SCALE = 0.8f +private const val UNCHECKED_SCALE = 1.0f + +private const val SHIMMER_INDEX = 0 +private const val THUMBNAIL_INDEX = 1 +private const val CHECKBOX_INDEX = 2 + class GalleryRowHolder( val binding: GalleryRowBinding, - private val defaultThumbnailSize: Float, private val ocFileListDelegate: OCFileListDelegate, - val storageManager: FileDataStorageManager, galleryAdapter: GalleryAdapter, private val viewThemeUtils: ViewThemeUtils ) : SectionedViewHolder(binding.root) { val context = galleryAdapter.context - private lateinit var currentRow: GalleryRow - - // Cached values private val zero by lazy { context.resources.getInteger(R.integer.zero) } private val smallMargin by lazy { context.resources.getInteger(R.integer.small_margin) } private val iconRadius by lazy { context.resources.getDimension(R.dimen.activity_icon_radius) } @@ -57,34 +57,41 @@ class GalleryRowHolder( ContextCompat.getDrawable(context, R.drawable.ic_checkbox_blank_outline) } - private var lastFileCount = -1 - // endregion - fun bind(row: GalleryRow) { - currentRow = row - val requiredCount = row.files.size - - // Only rebuild if file count changed - if (lastFileCount != requiredCount) { - binding.rowLayout.removeAllViews() - row.files.forEach { file -> - binding.rowLayout.addView(getRowLayout(file)) - } - lastFileCount = requiredCount + ensureCellCount(row.files.size) + + row.files.forEachIndexed { index, file -> + val size = row.cellSizes.getOrNull(index) ?: return@forEachIndexed + bindCell(index, file, size, isLast = index == row.files.lastIndex) } + } - val dimensions = getDimensions(row) + fun recycle() { + for (index in 0 until binding.rowLayout.childCount) { + ocFileListDelegate.cancelGalleryRow(thumbnailAt(index)) + } + } - for (i in row.files.indices) { - val dim = dimensions.getOrNull(i) ?: (defaultThumbnailSize.toInt() to defaultThumbnailSize.toInt()) - adjustFile(i, row.files[i], dim, row) + private fun ensureCellCount(count: Int) { + if (binding.rowLayout.childCount == count) { + return } + + binding.rowLayout.removeAllViews() + repeat(count) { binding.rowLayout.addView(createCell()) } } - fun updateRowVisuals() = bind(currentRow) + private fun createCell(): FrameLayout { + val shimmer = LoaderImageView(context).apply { + setImageResource(R.drawable.background) + resetLoader() + layoutParams = FrameLayout.LayoutParams(0, 0) + } - private fun getRowLayout(file: OCFile): FrameLayout { - val (width, height) = OCFileUtils.getImageSize(file, defaultThumbnailSize) + val thumbnail = ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = FrameLayout.LayoutParams(0, 0) + } val checkbox = ImageView(context).apply { visibility = View.GONE @@ -98,91 +105,59 @@ class GalleryRowHolder( } } - val shimmer = LoaderImageView(context).apply { - setImageResource(R.drawable.background) - resetLoader() - layoutParams = FrameLayout.LayoutParams(width, height) - } - - val drawable = OCFileUtils.getMediaPlaceholder(file, width to height) - val rowCellImageView = ImageView(context).apply { - setImageDrawable(drawable) - adjustViewBounds = true - scaleType = ImageView.ScaleType.CENTER_CROP - layoutParams = FrameLayout.LayoutParams(width, height) - } - return FrameLayout(context).apply { addView(shimmer) - addView(rowCellImageView) + addView(thumbnail) addView(checkbox) } } - private fun getDimensions(row: GalleryRow): List> { - val screenWidthPx = context.resources.displayMetrics.widthPixels.toFloat() - val marginPx = smallMargin.toFloat() - val totalMargins = marginPx * (row.files.size - 1) - val availableWidth = screenWidthPx - totalMargins + private fun bindCell(index: Int, file: OCFile, size: GalleryCellSize, isLast: Boolean) { + val frameLayout = binding.rowLayout[index] as FrameLayout + val shimmer = frameLayout[SHIMMER_INDEX] as LoaderImageView + val thumbnail = frameLayout[THUMBNAIL_INDEX] as ImageView + val checkbox = frameLayout[CHECKBOX_INDEX] as ImageView - val aspectRatios = row.files.map { file -> - val (w, h) = OCFileUtils.getImageSize(file, defaultThumbnailSize) - if (h > 0) w.toFloat() / h else 1.0f - } + applyCellSize(shimmer, size, endMargin = zero, bottomMargin = zero) + applyCellSize(thumbnail, size, endMargin = if (isLast) zero else smallMargin, bottomMargin = smallMargin) - val sumAspectRatios = aspectRatios.sum() + val isChecked = ocFileListDelegate.isCheckedFile(file) + applySelection(thumbnail, isChecked) + applyCheckBox(checkbox, isChecked) - // calculate row height based on aspect ratios - val rowHeightFloat = if (sumAspectRatios > 0) availableWidth / sumAspectRatios else defaultThumbnailSize - val finalHeight = rowHeightFloat.toInt() + ocFileListDelegate.bindGalleryRow(shimmer, thumbnail, file, this) + } - // for each aspect ratio calculate widths - val finalWidths = aspectRatios.map { ratio -> (rowHeightFloat * ratio).toInt() }.toMutableList() - val usedWidth = finalWidths.sum() + private fun applyCellSize(view: View, size: GalleryCellSize, endMargin: Int, bottomMargin: Int) { + val params = view.layoutParams as FrameLayout.LayoutParams - // based on screen width get remaining pixels - val remainingPixels = (availableWidth - usedWidth).toInt() + val unchanged = params.width == size.width && + params.height == size.height && + params.rightMargin == endMargin && + params.bottomMargin == bottomMargin - // add to remaining pixels to last image - if (remainingPixels > 0 && finalWidths.isNotEmpty()) { - val lastIndex = finalWidths.lastIndex - finalWidths[lastIndex] = finalWidths[lastIndex] + remainingPixels + if (unchanged) { + return } - return finalWidths.map { w -> w to finalHeight } + params.width = size.width + params.height = size.height + params.setMargins(0, 0, endMargin, bottomMargin) + view.layoutParams = params } - private fun adjustFile(index: Int, file: OCFile, dims: Pair, row: GalleryRow) { - val (width, height) = dims - val frameLayout = binding.rowLayout[index] as FrameLayout - val shimmer = frameLayout[0] as LoaderImageView - val thumbnail = frameLayout[1] as ImageView - val checkbox = frameLayout[2] as ImageView - - val isChecked = ocFileListDelegate.isCheckedFile(file) - adjustRowCell(thumbnail, isChecked) - adjustCheckBox(checkbox, isChecked) - - ocFileListDelegate.bindGalleryRow(shimmer, thumbnail, file, this, dims) - - val endMargin = if (index < row.files.size - 1) smallMargin else zero - thumbnail.layoutParams = FrameLayout.LayoutParams(width, height).apply { - setMargins(0, 0, endMargin, smallMargin) + private fun applySelection(imageView: ImageView, isChecked: Boolean) { + val scale = if (isChecked) CHECKED_SCALE else UNCHECKED_SCALE + if (imageView.scaleX == scale) { + return } - shimmer.layoutParams = FrameLayout.LayoutParams(width, height) - frameLayout.requestLayout() - } - @Suppress("MagicNumber") - private fun adjustRowCell(imageView: ImageView, isChecked: Boolean) { - val scale = if (isChecked) 0.8f else 1.0f - val radius = if (isChecked) iconRadius else 0f imageView.scaleX = scale imageView.scaleY = scale - imageView.makeRounded(context, radius) + imageView.makeRounded(context, if (isChecked) iconRadius else 0f) } - private fun adjustCheckBox(imageView: ImageView, isChecked: Boolean) { + private fun applyCheckBox(imageView: ImageView, isChecked: Boolean) { if (ocFileListDelegate.isMultiSelect) { val checkboxDrawable = if (isChecked) checkedDrawable else uncheckedDrawable @@ -191,7 +166,6 @@ class GalleryRowHolder( setBounds(margin, margin, margin, margin) } - // Only set if different if (imageView.drawable !== checkboxDrawable) { imageView.setImageDrawable(checkboxDrawable) } @@ -199,4 +173,7 @@ class GalleryRowHolder( imageView.setVisibleIf(ocFileListDelegate.isMultiSelect) } + + private fun thumbnailAt(index: Int): ImageView = + (binding.rowLayout[index] as FrameLayout)[THUMBNAIL_INDEX] as ImageView } From 8b2915f9171f7930d999619d03dc98870321de0a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:08:56 +0200 Subject: [PATCH 05/19] use new row data Signed-off-by: alperozturk96 --- .../nextcloud/utils/extensions/OCFileExtensions.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 464c8805ecfc..b9e8012aa969 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -10,6 +10,7 @@ package com.nextcloud.utils.extensions import com.owncloud.android.MainApp import com.owncloud.android.datamodel.GalleryItems import com.owncloud.android.datamodel.GalleryRow +import com.owncloud.android.datamodel.GalleryRowLayout import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.OCFileDepth import com.owncloud.android.datamodel.OCFileDepth.DeepLevel @@ -56,7 +57,7 @@ fun OCFile?.getDepth(): OCFileDepth? { return DeepLevel } -fun List.toGalleryItems(columns: Int, defaultSize: Int): List { +fun List.toGalleryItems(layout: GalleryRowLayout): List { if (isEmpty()) return emptyList() val calendar = Calendar.getInstance() @@ -70,18 +71,18 @@ fun List.toGalleryItems(columns: Int, defaultSize: Int): List - GalleryItems(date, transformToRows(filesList, columns, defaultSize)) + GalleryItems(date, transformToRows(filesList, layout)) } .sortedByDescending { it.date } } -private fun transformToRows(list: List, columns: Int, defaultSize: Int): List { +private fun transformToRows(list: List, layout: GalleryRowLayout): List { if (list.isEmpty()) return emptyList() return list .sortedByDescending { it.modificationTimestamp } - .chunked(columns) - .map { chunk -> GalleryRow(chunk, defaultSize, defaultSize) } + .chunked(layout.columns) + .map { chunk -> GalleryRow(chunk, layout.measure(chunk)) } } fun OCFile.toEncryptionEvent(encrypt: Boolean): EncryptionEvent = EncryptionEvent( From f8b86742166d2a6aedf03a8d774161747e1cedc5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:10:10 +0200 Subject: [PATCH 06/19] start coroutine lazy Signed-off-by: alperozturk96 --- .../android/ui/adapter/OCFileListDelegate.kt | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) 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 961bbfacfeb9..552432dcea37 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 @@ -46,6 +46,7 @@ import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren @@ -67,7 +68,6 @@ class OCFileListDelegate( private var viewThemeUtils: ViewThemeUtils, private val syncFolderProvider: SyncedFolderProvider? = null ) { - private val tag = "OCFileListDelegate" private val checkedFiles: MutableSet = HashSet() private var highlightedItem: OCFile? = null var isMultiSelect = false @@ -109,24 +109,25 @@ class OCFileListDelegate( shimmer: LoaderImageView?, imageView: ImageView, file: OCFile, - galleryRowHolder: GalleryRowHolder, - imageDimension: Pair + galleryRowHolder: GalleryRowHolder ) { GalleryImageGenerationJob.cancelPreviousJob(imageView) imageView.tag = file.fileId + bindGalleryRowListeners(imageView, file, galleryRowHolder) val displayable = file.takeUnless { it.isUpdateThumbnailNeeded }?.displayableThumbnailFromMemory() if (displayable != null) { + imageView.scaleType = ImageView.ScaleType.CENTER_CROP imageView.setImageBitmap(displayable) imageView.stopShimmer(shimmer) - bindGalleryRowListeners(imageView, file, galleryRowHolder) return } - imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file, imageDimension)) + imageView.scaleType = ImageView.ScaleType.FIT_XY + imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file)) - val job = ioScope.launch { + val job = ioScope.launch(start = CoroutineStart.LAZY) { try { galleryImageGenerationJob.run( file, @@ -134,22 +135,12 @@ class OCFileListDelegate( object : GalleryImageGenerationListener { override fun onSuccess() { if (imageView.tag == file.fileId) { - Log_OC.d(tag, "setGalleryImage.onSuccess()") - galleryRowHolder.binding.rowLayout.invalidate() imageView.stopShimmer(shimmer) } } - override fun onNewGalleryImage() { - if (imageView.tag == file.fileId) { - Log_OC.d(tag, "setGalleryImage.updateRowVisuals()") - galleryRowHolder.updateRowVisuals() - } - } - override fun onError() { if (imageView.tag == file.fileId) { - Log_OC.d(tag, "setGalleryImage.onError()") imageView.stopShimmer(shimmer) } } @@ -162,29 +153,28 @@ class OCFileListDelegate( } GalleryImageGenerationJob.storeJob(job, imageView) + job.start() + } - bindGalleryRowListeners(imageView, file, galleryRowHolder) + fun cancelGalleryRow(imageView: ImageView) { + GalleryImageGenerationJob.cancelPreviousJob(imageView) } - private fun OCFile.displayableThumbnailFromMemory(): Bitmap? { - val thumbnailKey = listOf(getBigThumbnailKey(), getSmallThumbnailKey()) - .firstOrNull { ThumbnailMemoryCache.get(it) != null } - ?: return null - val thumbnail = ThumbnailMemoryCache.get(thumbnailKey) + private fun OCFile.displayableThumbnailFromMemory(): Bitmap? = + cachedThumbnail(getBigThumbnailKey()) ?: cachedThumbnail(getSmallThumbnailKey()) - return when { - thumbnail == null -> null - MimeTypeUtil.isVideo(this) -> withVideoOverlay(thumbnailKey, thumbnail) - else -> thumbnail + private fun OCFile.cachedThumbnail(thumbnailKey: String): Bitmap? { + if (!MimeTypeUtil.isVideo(this)) { + return ThumbnailMemoryCache.get(thumbnailKey) } - } - private fun withVideoOverlay(thumbnailKey: String, thumbnail: Bitmap): Bitmap { val overlayKey = videoOverlayKey(thumbnailKey) return ThumbnailMemoryCache.get(overlayKey) - ?: ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also { - ThumbnailMemoryCache.put(overlayKey, it) + ?: ThumbnailMemoryCache.get(thumbnailKey)?.let { thumbnail -> + ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also { + ThumbnailMemoryCache.put(overlayKey, it) + } } } From 6ccaa98f6ec2a3cb311005677ce0e1db310c20d5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 12:11:01 +0200 Subject: [PATCH 07/19] adjust placeholders Signed-off-by: alperozturk96 --- .../java/com/nextcloud/utils/OCFileUtils.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt index d7bcfb1d40e9..a6f9237d230a 100644 --- a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt +++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt @@ -6,11 +6,11 @@ */ package com.nextcloud.utils +import android.graphics.Bitmap import android.graphics.Color +import android.graphics.drawable.Drawable import android.util.LruCache -import android.graphics.drawable.BitmapDrawable import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toDrawable import com.nextcloud.utils.extensions.getBitmapSize import com.nextcloud.utils.extensions.getExifSize @@ -25,8 +25,10 @@ import com.owncloud.android.utils.MimeTypeUtil object OCFileUtils { private const val TAG = "OCFileUtils" private const val IMAGE_SIZE_CACHE_ENTRIES = 2048 + private const val PLACEHOLDER_SIZE_PX = 256 private val imageSizes = LruCache>(IMAGE_SIZE_CACHE_ENTRIES) + private val placeholders = mutableMapOf() fun getImageSize(ocFile: OCFile, defaultThumbnailSize: Float): Pair { val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1) @@ -57,7 +59,7 @@ object OCFileUtils { return fallbackPair } - fun getMediaPlaceholder(file: OCFile, imageDimension: Pair): BitmapDrawable { + fun getMediaPlaceholder(file: OCFile): Drawable { val context = MainApp.getAppContext() val drawableId = if (MimeTypeUtil.isImage(file)) { @@ -68,15 +70,12 @@ object OCFileUtils { R.drawable.file } - val drawable = ContextCompat.getDrawable(context, drawableId) - ?: return Color.GRAY.toDrawable().toBitmap(imageDimension.first, imageDimension.second) - .toDrawable(context.resources) - - val bitmap = BitmapUtils.drawableToBitmap( - drawable, - imageDimension.first, - imageDimension.second - ) + val bitmap = synchronized(placeholders) { + placeholders.getOrPut(drawableId) { + val drawable = ContextCompat.getDrawable(context, drawableId) ?: Color.GRAY.toDrawable() + BitmapUtils.drawableToBitmap(drawable, PLACEHOLDER_SIZE_PX, PLACEHOLDER_SIZE_PX) + } + } return bitmap.toDrawable(context.resources) } From cfaa13a3540fda8dcef0a9be501edaab66db142d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 13:42:08 +0200 Subject: [PATCH 08/19] remove memory cache attempt Signed-off-by: alperozturk96 --- .../utils/thumbnail/ThumbnailMemoryCache.kt | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailMemoryCache.kt diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailMemoryCache.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailMemoryCache.kt deleted file mode 100644 index 397356914505..000000000000 --- a/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailMemoryCache.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.utils.thumbnail - -import android.graphics.Bitmap -import android.util.LruCache - -private const val BYTES_PER_KILOBYTE = 1024 -private const val HEAP_FRACTION = 8 - -object ThumbnailMemoryCache { - - private val cache = object : LruCache(maxSizeInKilobytes()) { - override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount / BYTES_PER_KILOBYTE - } - - fun get(key: String): Bitmap? = cache.get(key)?.takeUnless { it.isRecycled } - - fun put(key: String, bitmap: Bitmap) { - if (bitmap.isRecycled) { - return - } - - cache.put(key, bitmap) - } - - fun remove(key: String) { - cache.remove(key) - } - - fun clear() { - cache.evictAll() - } - - private fun maxSizeInKilobytes(): Int = - (Runtime.getRuntime().maxMemory() / BYTES_PER_KILOBYTE / HEAP_FRACTION).toInt() -} From f8bfacebf8980de03b3fc025b3a7df463538bd2e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 13:42:53 +0200 Subject: [PATCH 09/19] introduce gallery pagination state Signed-off-by: alperozturk96 --- .../android/ui/fragment/GalleryFragment.kt | 46 ++++++++++++++----- .../ui/fragment/GalleryPaginationState.kt | 14 ++++++ app/src/main/res/layout/list_fragment.xml | 23 ++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt 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 d7b835a2a3d5..9b7b5edabba4 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 @@ -31,10 +31,12 @@ import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.utils.extensions.getGalleryItemsPageSuspended import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getTypedActivity import com.nextcloud.utils.extensions.isLandscape +import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.extensions.toGalleryItems import com.owncloud.android.BuildConfig import com.owncloud.android.R @@ -62,7 +64,6 @@ import kotlinx.coroutines.withContext class GalleryFragment : OCFileListFragment(), GalleryFragmentBottomSheetActions { - var isPhotoSearchQueryRunning: Boolean = false private var photoSearchTask: Job? = null private var showGalleryJob: Job? = null private var endDate: Long = 0 @@ -73,6 +74,12 @@ class GalleryFragment : private var bottomSheet: GalleryFragmentBottomSheetDialog? = null + private var paginationState: GalleryPaginationState = GalleryPaginationState.IDLE + set(value) { + field = value + updatePaginationLoader() + } + override var columnsCount: Int = 0 private set @@ -209,6 +216,10 @@ class GalleryFragment : (recyclerView as EmptyRecyclerView).setHasFooter(false) } + binding?.paginationProgress?.let { + viewThemeUtils.platform.colorCircularProgressBar(it, ColorRole.PRIMARY) + } + val layoutManager = GridLayoutManager(context, 1) adapter?.setLayoutManager(layoutManager) recyclerView?.run { @@ -227,6 +238,7 @@ class GalleryFragment : override fun onRefresh() { super.onRefresh() + paginationState = GalleryPaginationState.IDLE handleSearchEvent() } @@ -263,19 +275,30 @@ class GalleryFragment : } private fun searchAndDisplay() { - if (!isPhotoSearchQueryRunning && endDate <= 0) { + if (paginationState == GalleryPaginationState.LOADING || endDate > 0) { // fix an issue when the method is called after loading the gallery and pressing play on a movie // to avoid reloading, check if endDate has already a value which is not -1 or 0 - endDate = System.currentTimeMillis() / 1000 - isPhotoSearchQueryRunning = true - runGallerySearchTask() + return } + + endDate = System.currentTimeMillis() / 1000 + runGallerySearchTask() + } + + private fun updatePaginationLoader() { + val loader = binding?.paginationLoader ?: return + val isLoadingNextPage = paginationState == GalleryPaginationState.LOADING && adapter?.isEmpty() == false + loader.setVisibleIf(isLoadingNextPage) } fun searchCompleted(result: GallerySearchTask.Result) { if (!isAdded) return - this.isPhotoSearchQueryRunning = false + paginationState = when { + result.resultCode != RemoteOperationResult.ResultCode.OK -> GalleryPaginationState.FAILED + result.emptySearch -> GalleryPaginationState.COMPLETED + else -> GalleryPaginationState.IDLE + } if (result.resultCode == RemoteOperationResult.ResultCode.OUT_OF_MEMORY) { setEmptyListMessage(EmptyListState.OUT_OF_MEMORY) @@ -320,11 +343,11 @@ class GalleryFragment : private fun searchAndDisplayAfterChangingFolder() { // TODO: Fix folder change, it seems it doesn't work at all + paginationState = GalleryPaginationState.IDLE loadedItemCount = INITIAL_GALLERY_WINDOW restoreScrollPending = false clearSavedViewState() endDate = System.currentTimeMillis() / 1000 - isPhotoSearchQueryRunning = true runGallerySearchTask() } @@ -334,6 +357,8 @@ class GalleryFragment : return } + paginationState = GalleryPaginationState.LOADING + photoSearchTask = GallerySearchTask( this, accountManager.user, @@ -344,8 +369,8 @@ class GalleryFragment : } private fun loadMoreWhenEndReached(recyclerView: RecyclerView, dy: Int) { - if (dy <= 0 || isPhotoSearchQueryRunning) { -// scrolling up or search query already active, do not search gallery + if (dy <= 0 || paginationState != GalleryPaginationState.IDLE) { +// scrolling up, a page is already loading, or there is nothing left to load return } @@ -372,7 +397,6 @@ class GalleryFragment : Log_OC.d(this, "Gallery swipe: retrieve items to check the chronology") } - this.isPhotoSearchQueryRunning = true runGallerySearchTask() // no more files in the gallery, retrieve the next ones } else if ((totalItemCount - visibleItemCount) <= (lastVisibleItem + MAX_ITEMS_PER_ROW) && @@ -386,12 +410,12 @@ class GalleryFragment : endDate = lastItemTimestamp loadedItemCount += GALLERY_WINDOW_INCREMENT showAllGalleryItems() - isPhotoSearchQueryRunning = true runGallerySearchTask() } } override fun updateMediaContent(mediaState: MediaState) { + paginationState = GalleryPaginationState.IDLE loadedItemCount = INITIAL_GALLERY_WINDOW restoreScrollPending = false clearSavedViewState() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt new file mode 100644 index 000000000000..55e0b27d4cef --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment + +enum class GalleryPaginationState { + IDLE, + LOADING, + COMPLETED, + FAILED +} diff --git a/app/src/main/res/layout/list_fragment.xml b/app/src/main/res/layout/list_fragment.xml index decc1b16b676..0122bd681f8b 100644 --- a/app/src/main/res/layout/list_fragment.xml +++ b/app/src/main/res/layout/list_fragment.xml @@ -12,6 +12,7 @@ --> @@ -33,6 +34,28 @@ + + + + + From 0763a6dc86d41fc3efc51b9c9c9291a6db34b5a7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 13:43:04 +0200 Subject: [PATCH 10/19] fix placeholder icon inset Signed-off-by: alperozturk96 --- .../jobs/gallery/GalleryImageGenerationJob.kt | 63 ++++++----------- .../java/com/nextcloud/utils/OCFileUtils.kt | 17 +---- .../utils/extensions/ImageViewExtensions.kt | 35 ++++++++++ .../utils/thumbnail/FileThumbnailGenerator.kt | 58 ++++++---------- .../datamodel/ThumbnailsCacheManager.java | 69 +++++++++++-------- .../android/ui/adapter/GalleryRowHolder.kt | 6 +- .../android/ui/adapter/OCFileListDelegate.kt | 50 +++++--------- app/src/main/res/values-night/colors.xml | 1 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dims.xml | 4 ++ app/src/main/res/values/ids.xml | 1 + 11 files changed, 146 insertions(+), 159 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt index d9216f9810a4..ecfedae885bd 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/gallery/GalleryImageGenerationJob.kt @@ -8,23 +8,21 @@ package com.nextcloud.client.jobs.gallery import android.graphics.Bitmap +import android.graphics.Point import android.media.ThumbnailUtils import android.os.Build import android.provider.MediaStore import android.util.Size +import android.view.WindowManager import android.widget.ImageView -import androidx.core.content.ContextCompat import com.nextcloud.client.account.User import com.nextcloud.utils.extensions.getBigThumbnail import com.nextcloud.utils.extensions.getBigThumbnailKey import com.nextcloud.utils.extensions.getSmallThumbnail -import com.nextcloud.utils.extensions.getSmallThumbnailKey import com.nextcloud.utils.extensions.isPNG +import com.nextcloud.utils.extensions.setMediaThumbnail import com.nextcloud.utils.extensions.toFile -import com.nextcloud.utils.extensions.videoOverlayKey -import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache import com.owncloud.android.MainApp -import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.ThumbnailsCacheManager @@ -115,24 +113,24 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag private suspend fun getBitmap(file: OCFile): Bitmap? = withContext(Dispatchers.IO) { val cached = file.getBigThumbnail() if (cached != null && !file.isUpdateThumbnailNeeded) { - return@withContext withCachedVideoOverlay(file, cached, file.getBigThumbnailKey()) + return@withContext cached } if (file.isDown) { val local = decodeLocalThumbnail(file) if (local != null) { ThumbnailsCacheManager.addBitmapToCache(file.getBigThumbnailKey(), local) - return@withContext withCachedVideoOverlay(file, local, file.getBigThumbnailKey()) + return@withContext local } } val remote = semaphore.withPermit { fetchFromServer(file) } if (remote != null) { - return@withContext withCachedVideoOverlay(file, remote, file.getBigThumbnailKey()) + return@withContext remote } file.getSmallThumbnail()?.let { small -> - return@withContext withCachedVideoOverlay(file, small, file.getSmallThumbnailKey()) + return@withContext small } null @@ -145,21 +143,22 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag } private fun createImageThumbnail(file: OCFile): Bitmap? { - val box = ThumbnailsCacheManager.getResizedImageDimension() + val wm = MainApp.getAppContext().getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager + val p = Point() + wm.defaultDisplay.getSize(p) - var bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, box.x, box.y) ?: return null + val pxW = p.x + val pxH = p.y + + val cacheKey = file.getBigThumbnailKey() + + var bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.storagePath, pxW, pxH) ?: return null if (file.isPNG()) { - bitmap = ThumbnailsCacheManager.handlePNG(bitmap, bitmap.width, bitmap.height) + bitmap = ThumbnailsCacheManager.handlePNG(bitmap, pxW, pxH) } - val thumbnail = ThumbnailsCacheManager.addThumbnailToCache( - file.getBigThumbnailKey(), - bitmap, - file.storagePath, - bitmap.width, - bitmap.height - ) + val thumbnail = ThumbnailsCacheManager.addThumbnailToCache(cacheKey, bitmap, file.storagePath, pxW, pxH) file.isUpdateThumbnailNeeded = false return thumbnail @@ -193,19 +192,6 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag null } - private fun withCachedVideoOverlay(file: OCFile, bitmap: Bitmap, sourceKey: String): Bitmap { - if (!MimeTypeUtil.isVideo(file)) { - return bitmap - } - - val overlayKey = videoOverlayKey(sourceKey) - ThumbnailMemoryCache.get(overlayKey)?.let { return it } - - return ThumbnailsCacheManager.addVideoOverlay(bitmap, MainApp.getAppContext()).also { - ThumbnailMemoryCache.put(overlayKey, it) - } - } - private suspend fun setThumbnail( bitmap: Bitmap, file: OCFile, @@ -214,17 +200,8 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag ) = withContext(Dispatchers.Main) { val tagId = file.fileId.toString() - if (imageView.tag.toString() == tagId) { - if (file.isPNG()) { - imageView.setBackgroundColor( - ContextCompat.getColor(MainApp.getAppContext(), R.color.bg_default) - ) - } - - if (imageView.isAttachedToWindow) { - imageView.scaleType = ImageView.ScaleType.CENTER_CROP - imageView.setImageBitmap(bitmap) - } + if (imageView.tag.toString() == tagId && imageView.isAttachedToWindow) { + imageView.setMediaThumbnail(file, bitmap) } listener.onSuccess() diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt index a6f9237d230a..8e227033fe33 100644 --- a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt +++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt @@ -6,29 +6,23 @@ */ package com.nextcloud.utils -import android.graphics.Bitmap -import android.graphics.Color import android.graphics.drawable.Drawable import android.util.LruCache import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable import com.nextcloud.utils.extensions.getBitmapSize import com.nextcloud.utils.extensions.getExifSize import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.utils.BitmapUtils import com.owncloud.android.utils.MimeTypeUtil @Suppress("TooGenericExceptionCaught", "ReturnCount") object OCFileUtils { private const val TAG = "OCFileUtils" private const val IMAGE_SIZE_CACHE_ENTRIES = 2048 - private const val PLACEHOLDER_SIZE_PX = 256 private val imageSizes = LruCache>(IMAGE_SIZE_CACHE_ENTRIES) - private val placeholders = mutableMapOf() fun getImageSize(ocFile: OCFile, defaultThumbnailSize: Float): Pair { val fallback = defaultThumbnailSize.toInt().coerceAtLeast(1) @@ -59,7 +53,7 @@ object OCFileUtils { return fallbackPair } - fun getMediaPlaceholder(file: OCFile): Drawable { + fun getMediaPlaceholder(file: OCFile): Drawable? { val context = MainApp.getAppContext() val drawableId = if (MimeTypeUtil.isImage(file)) { @@ -70,13 +64,6 @@ object OCFileUtils { R.drawable.file } - val bitmap = synchronized(placeholders) { - placeholders.getOrPut(drawableId) { - val drawable = ContextCompat.getDrawable(context, drawableId) ?: Color.GRAY.toDrawable() - BitmapUtils.drawableToBitmap(drawable, PLACEHOLDER_SIZE_PX, PLACEHOLDER_SIZE_PX) - } - } - - return bitmap.toDrawable(context.resources) + return ContextCompat.getDrawable(context, drawableId) } } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt index 4f66f3fe17e7..4c35af4accca 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt @@ -8,6 +8,7 @@ package com.nextcloud.utils.extensions import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.GradientDrawable import android.util.TypedValue import android.view.View @@ -17,7 +18,10 @@ import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.nextcloud.utils.OCFileUtils import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeTypeUtil fun ImageView.startShimmer(shimmer: LoaderImageView) { shimmer.setImageResource(R.drawable.background) @@ -33,6 +37,37 @@ fun ImageView.stopShimmer(shimmer: LoaderImageView?) { setVisibility(View.VISIBLE) } +fun ImageView.setMediaPlaceholder(file: OCFile, iconInset: Int) { + scaleType = ImageView.ScaleType.FIT_CENTER + setPadding(iconInset, iconInset, iconInset, iconInset) + setBackgroundResource(R.color.media_placeholder_background) + foreground = null + setImageDrawable(OCFileUtils.getMediaPlaceholder(file)) + setTag(R.id.media_thumbnail_file_id, null) +} + +fun ImageView.setMediaThumbnail(file: OCFile, bitmap: Bitmap) { + scaleType = ImageView.ScaleType.CENTER_CROP + setPadding(0, 0, 0, 0) + + if (file.isPNG()) { + setBackgroundResource(R.color.bg_default) + } else { + background = null + } + + foreground = if (MimeTypeUtil.isVideo(file)) { + ContextCompat.getDrawable(context, R.drawable.video_white) + } else { + null + } + + setImageBitmap(bitmap) + setTag(R.id.media_thumbnail_file_id, file.fileId) +} + +fun ImageView.showsMediaThumbnailOf(file: OCFile): Boolean = getTag(R.id.media_thumbnail_file_id) == file.fileId + @JvmOverloads fun ImageView.makeRoundedWithIcon( context: Context, diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt index 19365ecb70d5..0aff78089f5f 100644 --- a/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt @@ -25,7 +25,6 @@ import com.nextcloud.utils.extensions.getSmallThumbnailKey import com.nextcloud.utils.extensions.startShimmer import com.nextcloud.utils.extensions.stopShimmer import com.nextcloud.utils.extensions.toFile -import com.nextcloud.utils.extensions.videoOverlayKey import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile @@ -69,6 +68,7 @@ class FileThumbnailGenerator @Inject constructor( ) private val tasks = CopyOnWriteArrayList() + private val placeholders = mutableMapOf() fun setThumbnail(file: OCFile, view: ImageView, arguments: ThumbnailArguments) { if (file.remoteId == null) { @@ -76,12 +76,7 @@ class FileThumbnailGenerator @Inject constructor( return } - if (!file.isPreviewAvailable) { - generate(file, view, arguments) - return - } - - val cached = ThumbnailMemoryCache.get(file.getSmallThumbnailKey()) + val cached = ThumbnailsCacheManager.getBitmapFromDiskCache(file.getSmallThumbnailKey()) if (cached == null || file.isUpdateThumbnailNeeded) { generate(file, view, arguments) } else { @@ -91,6 +86,16 @@ class FileThumbnailGenerator @Inject constructor( applyPngBackground(file, view) } + private fun show(bitmap: Bitmap, file: OCFile, view: ImageView, arguments: ThumbnailArguments) { + view.stopShimmer(arguments.shimmer) + + if (MimeTypeUtil.isVideo(file) && !arguments.hideVideoOverlay) { + view.setImageBitmap(ThumbnailsCacheManager.addVideoOverlay(bitmap, context)) + } else { + BitmapUtils.setRoundedBitmapAccordingToListType(arguments.isGrid, bitmap, view) + } + } + fun setOfflineOperationThumbnail(file: OCFile, view: ImageView) { if (file.isFolder) { view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_folder_offline)) @@ -117,25 +122,6 @@ class FileThumbnailGenerator @Inject constructor( tasks.clear() } - private fun show(bitmap: Bitmap, file: OCFile, view: ImageView, arguments: ThumbnailArguments) { - view.stopShimmer(arguments.shimmer) - - if (MimeTypeUtil.isVideo(file) && !arguments.hideVideoOverlay) { - view.setImageBitmap(file.withVideoOverlay(bitmap)) - } else { - BitmapUtils.setRoundedBitmapAccordingToListType(arguments.isGrid, bitmap, view) - } - } - - private fun OCFile.withVideoOverlay(thumbnail: Bitmap): Bitmap { - val overlayKey = videoOverlayKey(getSmallThumbnailKey()) - ThumbnailMemoryCache.get(overlayKey)?.let { return it } - - return ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also { - ThumbnailMemoryCache.put(overlayKey, it) - } - } - private fun setLocalThumbnail(file: OCFile, view: ImageView, arguments: ThumbnailArguments) { val localFile = file.storagePath.toFile() @@ -159,12 +145,6 @@ class FileThumbnailGenerator @Inject constructor( return } - val cached = ThumbnailMemoryCache.get(file.getSmallThumbnailKey()) - if (cached != null) { - show(cached, file, view, arguments) - return - } - tasks.removeIf { it.isCancelled || it.status == AsyncTask.Status.FINISHED } startTask( @@ -244,13 +224,15 @@ class FileThumbnailGenerator @Inject constructor( private fun OCFile.mimeIcon(): Drawable? = MimeTypeUtil.getFileTypeIcon(mimeType, fileName, context, viewThemeUtils) - private fun OCFile.placeholder(): Bitmap { - val drawable = mimeIcon() - ?: ResourcesCompat.getDrawable(context.resources, R.drawable.file_image, null) - ?: Color.GRAY.toDrawable() - val size = ThumbnailsCacheManager.getThumbnailDimension() + private fun OCFile.placeholder(): Bitmap = synchronized(placeholders) { + placeholders.getOrPut(mimeType.orEmpty()) { + val drawable = mimeIcon() + ?: ResourcesCompat.getDrawable(context.resources, R.drawable.file_image, null) + ?: Color.GRAY.toDrawable() + val size = ThumbnailsCacheManager.getThumbnailDimension() - return BitmapUtils.drawableToBitmap(drawable, size, size) + BitmapUtils.drawableToBitmap(drawable, size, size) + } } private fun applyPngBackground(file: ServerFileInterface, view: ImageView) { diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 1328dd5f7d91..e74d387e6710 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -39,7 +39,6 @@ import com.nextcloud.client.account.User; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.BitmapExtensionsKt; -import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt; @@ -119,6 +118,17 @@ public final class ThumbnailsCacheManager { private static OwnCloudClient mClient; public static final int THUMBNAIL_SIZE_IN_KB = 512; private static final int RESIZED_IMAGE_SIZE_IN_KB = 10240; + private static final float PLAY_BUTTON_SIZE_IN_DP = 24f; + private static final int PLAY_BUTTON_ALPHA = 230; + + private static volatile Bitmap mPlayButton; + private static final Paint mPlayButtonPaint = createPlayButtonPaint(); + + private static Paint createPlayButtonPaint() { + Paint paint = new Paint(); + paint.setAlpha(PLAY_BUTTON_ALPHA); + return paint; + } public static final Bitmap mDefaultImg = BitmapFactory.decodeResource(MainApp.getAppContext().getResources(), R.drawable.file_image); @@ -229,10 +239,6 @@ public static void removeFromCache(@Nullable OCFile file) { List keys = FileExtensionsKt.getThumbnailKeys(file); - for (String key : keys) { - ThumbnailMemoryCache.INSTANCE.remove(key); - } - synchronized (mThumbnailsDiskCacheLock) { if (mThumbnailCache == null) { return; @@ -275,7 +281,6 @@ public static void addBitmapToCache(String key, Bitmap bitmap) { } mThumbnailCache.put(key, bitmap); - ThumbnailMemoryCache.INSTANCE.put(key, bitmap); } } @@ -301,11 +306,6 @@ public static Bitmap getScaledBitmapFromDiskCache(String key, int width, int hei } public static Bitmap getBitmapFromDiskCache(String key) { - Bitmap fromMemory = ThumbnailMemoryCache.INSTANCE.get(key); - if (fromMemory != null) { - return fromMemory; - } - synchronized (mThumbnailsDiskCacheLock) { // Wait while disk cache is started from background thread while (mThumbnailCacheStarting) { @@ -316,11 +316,7 @@ public static Bitmap getBitmapFromDiskCache(String key) { } } if (mThumbnailCache != null) { - Bitmap fromDisk = mThumbnailCache.getBitmap(key); - if (fromDisk != null) { - ThumbnailMemoryCache.INSTANCE.put(key, fromDisk); - } - return fromDisk; + return mThumbnailCache.getBitmap(key); } } return null; @@ -1121,36 +1117,49 @@ private static ResizedImageGenerationTask getResizedImageGenerationWorkerTask(Im } public static Bitmap addVideoOverlay(Bitmap thumbnail, Context context) { + Bitmap playButton = getPlayButton(context); + if (playButton == null) { + return thumbnail; + } - Drawable playButtonDrawable = ResourcesCompat.getDrawable(MainApp.getAppContext().getResources(), - R.drawable.video_white, - null); - - int px = DisplayUtils.convertDpToPixel(24f, context); - - Bitmap playButton = BitmapUtils.drawableToBitmap(playButtonDrawable, px, px); - - Bitmap resizedPlayButton = Bitmap.createScaledBitmap(playButton, px, px, true); + int px = playButton.getWidth(); Bitmap resultBitmap = Bitmap.createBitmap(thumbnail.getWidth(), thumbnail.getHeight(), Bitmap.Config.ARGB_8888); Canvas c = new Canvas(resultBitmap); - - c.drawBitmap(thumbnail, 0, 0, null); float left = (thumbnail.getWidth() - px) / 2f; float top = (thumbnail.getHeight() - px) / 2f; - Paint p = new Paint(); - p.setAlpha(230); - c.drawBitmap(resizedPlayButton, left, top, p); + c.drawBitmap(playButton, left, top, mPlayButtonPaint); return resultBitmap; } + @Nullable + private static Bitmap getPlayButton(Context context) { + Bitmap cached = mPlayButton; + if (cached != null && !cached.isRecycled()) { + return cached; + } + + Drawable playButtonDrawable = ResourcesCompat.getDrawable(MainApp.getAppContext().getResources(), + R.drawable.video_white, + null); + if (playButtonDrawable == null) { + return null; + } + + int px = DisplayUtils.convertDpToPixel(PLAY_BUTTON_SIZE_IN_DP, context); + cached = BitmapUtils.drawableToBitmap(playButtonDrawable, px, px); + mPlayButton = cached; + + return cached; + } + public static class AsyncThumbnailDrawable extends BitmapDrawable { private final WeakReference bitmapWorkerTaskReference; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt index dd3f097b4c20..f6b3268b5694 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt @@ -27,6 +27,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.theme.ViewThemeUtils private const val CHECKED_SCALE = 0.8f +private const val PLACEHOLDER_ICON_INSET_RATIO = 0.32f private const val UNCHECKED_SCALE = 1.0f private const val SHIMMER_INDEX = 0 @@ -125,9 +126,12 @@ class GalleryRowHolder( applySelection(thumbnail, isChecked) applyCheckBox(checkbox, isChecked) - ocFileListDelegate.bindGalleryRow(shimmer, thumbnail, file, this) + ocFileListDelegate.bindGalleryRow(shimmer, thumbnail, file, this, placeholderInset(size)) } + private fun placeholderInset(size: GalleryCellSize): Int = + (minOf(size.width, size.height) * PLACEHOLDER_ICON_INSET_RATIO).toInt() + private fun applyCellSize(view: View, size: GalleryCellSize, endMargin: Int, bottomMargin: Int) { val params = view.layoutParams as FrameLayout.LayoutParams 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 552432dcea37..6d884f09aa0b 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 @@ -8,7 +8,6 @@ package com.owncloud.android.ui.adapter import android.content.Context -import android.graphics.Bitmap import android.view.View import android.widget.ImageView import androidx.core.content.ContextCompat @@ -19,17 +18,16 @@ import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.gallery.GalleryImageGenerationJob import com.nextcloud.client.jobs.gallery.GalleryImageGenerationListener import com.nextcloud.client.jobs.upload.FileUploadHelper -import com.nextcloud.utils.OCFileUtils -import com.nextcloud.utils.extensions.getBigThumbnailKey -import com.nextcloud.utils.extensions.getSmallThumbnailKey +import com.nextcloud.utils.extensions.getBigThumbnail import com.nextcloud.utils.extensions.makeRounded +import com.nextcloud.utils.extensions.setMediaPlaceholder +import com.nextcloud.utils.extensions.setMediaThumbnail +import com.nextcloud.utils.extensions.showsMediaThumbnailOf import com.nextcloud.utils.extensions.setVisibleIf import com.nextcloud.utils.extensions.stopShimmer import com.nextcloud.utils.mdm.MDMConfig -import com.nextcloud.utils.extensions.videoOverlayKey import com.nextcloud.utils.thumbnail.ThumbnailArguments import com.nextcloud.utils.thumbnail.ThumbnailGenerator -import com.nextcloud.utils.thumbnail.ThumbnailMemoryCache import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile @@ -109,23 +107,29 @@ class OCFileListDelegate( shimmer: LoaderImageView?, imageView: ImageView, file: OCFile, - galleryRowHolder: GalleryRowHolder + galleryRowHolder: GalleryRowHolder, + placeholderInset: Int ) { + bindGalleryRowListeners(imageView, file, galleryRowHolder) + + if (imageView.showsMediaThumbnailOf(file) && !file.isUpdateThumbnailNeeded) { + imageView.tag = file.fileId + imageView.stopShimmer(shimmer) + return + } + GalleryImageGenerationJob.cancelPreviousJob(imageView) imageView.tag = file.fileId - bindGalleryRowListeners(imageView, file, galleryRowHolder) - val displayable = file.takeUnless { it.isUpdateThumbnailNeeded }?.displayableThumbnailFromMemory() - if (displayable != null) { - imageView.scaleType = ImageView.ScaleType.CENTER_CROP - imageView.setImageBitmap(displayable) + val cached = file.takeUnless { it.isUpdateThumbnailNeeded }?.getBigThumbnail() + if (cached != null) { + imageView.setMediaThumbnail(file, cached) imageView.stopShimmer(shimmer) return } - imageView.scaleType = ImageView.ScaleType.FIT_XY - imageView.setImageDrawable(OCFileUtils.getMediaPlaceholder(file)) + imageView.setMediaPlaceholder(file, placeholderInset) val job = ioScope.launch(start = CoroutineStart.LAZY) { try { @@ -160,24 +164,6 @@ class OCFileListDelegate( GalleryImageGenerationJob.cancelPreviousJob(imageView) } - private fun OCFile.displayableThumbnailFromMemory(): Bitmap? = - cachedThumbnail(getBigThumbnailKey()) ?: cachedThumbnail(getSmallThumbnailKey()) - - private fun OCFile.cachedThumbnail(thumbnailKey: String): Bitmap? { - if (!MimeTypeUtil.isVideo(this)) { - return ThumbnailMemoryCache.get(thumbnailKey) - } - - val overlayKey = videoOverlayKey(thumbnailKey) - - return ThumbnailMemoryCache.get(overlayKey) - ?: ThumbnailMemoryCache.get(thumbnailKey)?.let { thumbnail -> - ThumbnailsCacheManager.addVideoOverlay(thumbnail, context).also { - ThumbnailMemoryCache.put(overlayKey, it) - } - } - } - private fun bindGalleryRowListeners(imageView: ImageView, file: OCFile, galleryRowHolder: GalleryRowHolder) { imageView.setOnClickListener { if (context is AlbumsPickerActivity) { diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 72908c65f15b..6979627fc58a 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -17,6 +17,7 @@ #ffffff #121212 + #2C2C2C #000000 #818181 #222222 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e318d3516012..afad3b71b8d4 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -53,6 +53,7 @@ #ffffff #ffffff #FFFFFF + #EEEEEE #000000 @color/color_accent #ffffff diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 9d26887845be..e24be6d7d9be 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -26,6 +26,10 @@ 3dp 512 28dp + 24dp + 4dp + 24dp + 3dp 16dp 12dp 32dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f939274621ff..952ca315f9c3 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -42,6 +42,7 @@ + From 3221f2f7abc8dc2e0a86d96d22d7a59281d910c1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 14:46:10 +0200 Subject: [PATCH 11/19] use VideoOverlayGenerator Signed-off-by: alperozturk96 --- .../utils/thumbnail/FileThumbnailGenerator.kt | 2 +- .../utils/thumbnail/VideoOverlayGenerator.kt | 61 ++++++++++++++++++ .../datamodel/ThumbnailsCacheManager.java | 64 ++----------------- .../android/ui/activity/EditorWebView.java | 4 +- .../ui/adapter/TrashbinListAdapter.java | 3 +- 5 files changed, 70 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt index 0aff78089f5f..91000ecbf005 100644 --- a/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/FileThumbnailGenerator.kt @@ -90,7 +90,7 @@ class FileThumbnailGenerator @Inject constructor( view.stopShimmer(arguments.shimmer) if (MimeTypeUtil.isVideo(file) && !arguments.hideVideoOverlay) { - view.setImageBitmap(ThumbnailsCacheManager.addVideoOverlay(bitmap, context)) + view.setImageBitmap(VideoOverlayGenerator.addOverlay(bitmap, context)) } else { BitmapUtils.setRoundedBitmapAccordingToListType(arguments.isGrid, bitmap, view) } diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt new file mode 100644 index 000000000000..e0149d73fe9d --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.thumbnail + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import com.owncloud.android.R +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils + +object VideoOverlayGenerator { + + private const val PLAY_BUTTON_SIZE_IN_DP = 24f + private const val PLAY_BUTTON_ALPHA = 230 + + private val playButtonPaint = Paint().apply { alpha = PLAY_BUTTON_ALPHA } + + @Volatile + private var playButton: Bitmap? = null + + @JvmStatic + fun addOverlay(thumbnail: Bitmap, context: Context): Bitmap { + val overlay = getPlayButton(context) ?: return thumbnail + + return createBitmap(thumbnail.width, thumbnail.height).applyCanvas { + drawBitmap(thumbnail, 0f, 0f, null) + drawBitmap( + overlay, + (thumbnail.width - overlay.width) / 2f, + (thumbnail.height - overlay.height) / 2f, + playButtonPaint + ) + } + } + + private fun getPlayButton(context: Context): Bitmap? { + cachedPlayButton()?.let { return it } + + return synchronized(this) { + cachedPlayButton() ?: createPlayButton(context)?.also { playButton = it } + } + } + + private fun cachedPlayButton(): Bitmap? = playButton?.takeIf { !it.isRecycled } + + private fun createPlayButton(context: Context): Bitmap? { + val drawable = ResourcesCompat.getDrawable(context.resources, R.drawable.video_white, null) + ?: return null + val px = DisplayUtils.convertDpToPixel(PLAY_BUTTON_SIZE_IN_DP, context) + return BitmapUtils.drawableToBitmap(drawable, px, px) + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index e74d387e6710..431822d694c8 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -19,7 +19,6 @@ import android.graphics.Bitmap.CompressFormat; import android.graphics.BitmapFactory; import android.graphics.Canvas; -import android.graphics.Paint; import android.graphics.Point; import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; @@ -42,6 +41,7 @@ import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.nextcloud.utils.extensions.OwnCloudClientExtensionsKt; +import com.nextcloud.utils.thumbnail.VideoOverlayGenerator; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.lib.common.OwnCloudAccount; @@ -57,7 +57,6 @@ import com.owncloud.android.ui.fragment.FileFragment; import com.owncloud.android.ui.preview.PreviewImageFragment; import com.owncloud.android.utils.BitmapUtils; -import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeTypeUtil; @@ -118,17 +117,6 @@ public final class ThumbnailsCacheManager { private static OwnCloudClient mClient; public static final int THUMBNAIL_SIZE_IN_KB = 512; private static final int RESIZED_IMAGE_SIZE_IN_KB = 10240; - private static final float PLAY_BUTTON_SIZE_IN_DP = 24f; - private static final int PLAY_BUTTON_ALPHA = 230; - - private static volatile Bitmap mPlayButton; - private static final Paint mPlayButtonPaint = createPlayButtonPaint(); - - private static Paint createPlayButtonPaint() { - Paint paint = new Paint(); - paint.setAlpha(PLAY_BUTTON_ALPHA); - return paint; - } public static final Bitmap mDefaultImg = BitmapFactory.decodeResource(MainApp.getAppContext().getResources(), R.drawable.file_image); @@ -375,7 +363,7 @@ protected Bitmap doInBackground(Object... params) { thumbnail = doResizedImageInBackground(file, storageManager); if (MimeTypeUtil.isVideo(file) && thumbnail != null) { - thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + thumbnail = VideoOverlayGenerator.addOverlay(thumbnail, MainApp.getAppContext()); } } catch (OutOfMemoryError oome) { @@ -530,7 +518,7 @@ protected Bitmap doInBackground(ThumbnailGenerationTaskObject... params) { thumbnail = doThumbnailFromOCFileInBackground(); if (MimeTypeUtil.isVideo((ServerFileInterface) mFile) && thumbnail != null && !hideVideoOverlay) { - thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + thumbnail = VideoOverlayGenerator.addOverlay(thumbnail, MainApp.getAppContext()); } } else if (mFile instanceof File) { thumbnail = doFileInBackground(); @@ -539,7 +527,7 @@ protected Bitmap doInBackground(ThumbnailGenerationTaskObject... params) { String mMimeType = FileStorageUtils.getMimeTypeFromName(url); if (MimeTypeUtil.isVideo(mMimeType) && thumbnail != null && !hideVideoOverlay) { - thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); + thumbnail = VideoOverlayGenerator.addOverlay(thumbnail, MainApp.getAppContext()); } //} else { do nothing } @@ -1116,50 +1104,6 @@ private static ResizedImageGenerationTask getResizedImageGenerationWorkerTask(Im return null; } - public static Bitmap addVideoOverlay(Bitmap thumbnail, Context context) { - Bitmap playButton = getPlayButton(context); - if (playButton == null) { - return thumbnail; - } - - int px = playButton.getWidth(); - - Bitmap resultBitmap = Bitmap.createBitmap(thumbnail.getWidth(), - thumbnail.getHeight(), - Bitmap.Config.ARGB_8888); - - Canvas c = new Canvas(resultBitmap); - c.drawBitmap(thumbnail, 0, 0, null); - - float left = (thumbnail.getWidth() - px) / 2f; - float top = (thumbnail.getHeight() - px) / 2f; - - c.drawBitmap(playButton, left, top, mPlayButtonPaint); - - return resultBitmap; - } - - @Nullable - private static Bitmap getPlayButton(Context context) { - Bitmap cached = mPlayButton; - if (cached != null && !cached.isRecycled()) { - return cached; - } - - Drawable playButtonDrawable = ResourcesCompat.getDrawable(MainApp.getAppContext().getResources(), - R.drawable.video_white, - null); - if (playButtonDrawable == null) { - return null; - } - - int px = DisplayUtils.convertDpToPixel(PLAY_BUTTON_SIZE_IN_DP, context); - cached = BitmapUtils.drawableToBitmap(playButtonDrawable, px, px); - mPlayButton = cached; - - return cached; - } - public static class AsyncThumbnailDrawable extends BitmapDrawable { private final WeakReference bitmapWorkerTaskReference; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java index d32496a69b79..784a8117588b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java @@ -27,12 +27,12 @@ import com.nextcloud.client.account.User; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.nextcloud.utils.thumbnail.VideoOverlayGenerator; import com.owncloud.android.R; import com.owncloud.android.databinding.RichdocumentsWebviewBinding; import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.SyncedFolderObserver; import com.owncloud.android.datamodel.SyncedFolderProvider; -import com.owncloud.android.datamodel.ThumbnailsCacheManager; import com.owncloud.android.ui.asynctasks.TextEditorLoadUrlTask; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.MimeTypeUtil; @@ -264,7 +264,7 @@ protected void setThumbnailView(final User user) { if (thumbnail != null && !file.isUpdateThumbnailNeeded()) { if (MimeTypeUtil.isVideo(file)) { - Bitmap withOverlay = ThumbnailsCacheManager.addVideoOverlay(thumbnail, this); + Bitmap withOverlay = VideoOverlayGenerator.addOverlay(thumbnail, this); binding.thumbnail.setImageBitmap(withOverlay); } else { binding.thumbnail.setImageBitmap(thumbnail); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java index 3f281d0704d9..3d1595be438f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java @@ -22,6 +22,7 @@ import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; +import com.nextcloud.utils.thumbnail.VideoOverlayGenerator; import com.owncloud.android.R; import com.owncloud.android.databinding.ListFooterBinding; import com.owncloud.android.databinding.TrashbinItemBinding; @@ -260,7 +261,7 @@ private void setThumbnail(TrashbinFile file, ImageView thumbnailView) { Bitmap thumbnail = FileExtensionsKt.getSmallThumbnail(file); if (thumbnail != null) { if (MimeTypeUtil.isVideo(file)) { - Bitmap withOverlay = ThumbnailsCacheManager.addVideoOverlay(thumbnail, context); + Bitmap withOverlay = VideoOverlayGenerator.addOverlay(thumbnail, context); thumbnailView.setImageBitmap(withOverlay); } else { thumbnailView.setImageBitmap(thumbnail); From a0598e97e836ca769bbf835fd23b05de9885236e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 14:51:41 +0200 Subject: [PATCH 12/19] make play button density aware Signed-off-by: alperozturk96 --- .../utils/thumbnail/VideoOverlayGenerator.kt | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt index e0149d73fe9d..0522de7b6952 100644 --- a/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/VideoOverlayGenerator.kt @@ -22,14 +22,12 @@ object VideoOverlayGenerator { private const val PLAY_BUTTON_SIZE_IN_DP = 24f private const val PLAY_BUTTON_ALPHA = 230 - private val playButtonPaint = Paint().apply { alpha = PLAY_BUTTON_ALPHA } - - @Volatile - private var playButton: Bitmap? = null + private val playButtons = mutableMapOf() @JvmStatic fun addOverlay(thumbnail: Bitmap, context: Context): Bitmap { val overlay = getPlayButton(context) ?: return thumbnail + val paint = Paint().apply { alpha = PLAY_BUTTON_ALPHA } return createBitmap(thumbnail.width, thumbnail.height).applyCanvas { drawBitmap(thumbnail, 0f, 0f, null) @@ -37,21 +35,18 @@ object VideoOverlayGenerator { overlay, (thumbnail.width - overlay.width) / 2f, (thumbnail.height - overlay.height) / 2f, - playButtonPaint + paint ) } } - private fun getPlayButton(context: Context): Bitmap? { - cachedPlayButton()?.let { return it } + private fun getPlayButton(context: Context): Bitmap? = synchronized(playButtons) { + val densityDpi = context.resources.displayMetrics.densityDpi - return synchronized(this) { - cachedPlayButton() ?: createPlayButton(context)?.also { playButton = it } - } + playButtons[densityDpi]?.takeIf { !it.isRecycled } + ?: createPlayButton(context)?.also { playButtons[densityDpi] = it } } - private fun cachedPlayButton(): Bitmap? = playButton?.takeIf { !it.isRecycled } - private fun createPlayButton(context: Context): Bitmap? { val drawable = ResourcesCompat.getDrawable(context.resources, R.drawable.video_white, null) ?: return null From f4c475cc40b8bcc0acdc3583d92b776431b3cf9f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 14:53:27 +0200 Subject: [PATCH 13/19] remove unsued import Signed-off-by: alperozturk96 --- .../java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 6d884f09aa0b..095e258e3923 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 @@ -22,8 +22,8 @@ import com.nextcloud.utils.extensions.getBigThumbnail import com.nextcloud.utils.extensions.makeRounded import com.nextcloud.utils.extensions.setMediaPlaceholder import com.nextcloud.utils.extensions.setMediaThumbnail -import com.nextcloud.utils.extensions.showsMediaThumbnailOf import com.nextcloud.utils.extensions.setVisibleIf +import com.nextcloud.utils.extensions.showsMediaThumbnailOf import com.nextcloud.utils.extensions.stopShimmer import com.nextcloud.utils.mdm.MDMConfig import com.nextcloud.utils.thumbnail.ThumbnailArguments @@ -32,7 +32,6 @@ import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolderProvider -import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.AlbumsPickerActivity import com.owncloud.android.ui.activity.ComponentsGetter From 911a23cdb8f33316c50868ec9f7e2b3f606ede27 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 14:58:42 +0200 Subject: [PATCH 14/19] format comment block Signed-off-by: alperozturk96 --- .../java/com/owncloud/android/ui/fragment/GalleryFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9b7b5edabba4..7f0f80fd8a42 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 @@ -370,7 +370,7 @@ class GalleryFragment : private fun loadMoreWhenEndReached(recyclerView: RecyclerView, dy: Int) { if (dy <= 0 || paginationState != GalleryPaginationState.IDLE) { -// scrolling up, a page is already loading, or there is nothing left to load + // scrolling up, a page is already loading, or there is nothing left to load return } From 35d68cc54310c7802e22f5c9e6dfaa117d4ff2f2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 15:20:56 +0200 Subject: [PATCH 15/19] no need enum, just show spinner or dont show Signed-off-by: alperozturk96 --- .../android/ui/fragment/GalleryFragment.kt | 26 ++++++++----------- .../ui/fragment/GalleryPaginationState.kt | 14 ---------- 2 files changed, 11 insertions(+), 29 deletions(-) delete mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt 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 7f0f80fd8a42..85b97a8ac430 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 @@ -74,7 +74,7 @@ class GalleryFragment : private var bottomSheet: GalleryFragmentBottomSheetDialog? = null - private var paginationState: GalleryPaginationState = GalleryPaginationState.IDLE + private var isLoadingNextPage = false set(value) { field = value updatePaginationLoader() @@ -159,6 +159,7 @@ class GalleryFragment : override fun onPause() { super.onPause() photoSearchTask?.cancel() + isLoadingNextPage = false savedScrollState = recyclerView?.layoutManager?.onSaveInstanceState() savedLoadedItemCount = loadedItemCount savedMediaState = bottomSheet?.currMediaState @@ -238,7 +239,7 @@ class GalleryFragment : override fun onRefresh() { super.onRefresh() - paginationState = GalleryPaginationState.IDLE + isLoadingNextPage = false handleSearchEvent() } @@ -275,7 +276,7 @@ class GalleryFragment : } private fun searchAndDisplay() { - if (paginationState == GalleryPaginationState.LOADING || endDate > 0) { + if (isLoadingNextPage || endDate > 0) { // fix an issue when the method is called after loading the gallery and pressing play on a movie // to avoid reloading, check if endDate has already a value which is not -1 or 0 return @@ -287,18 +288,13 @@ class GalleryFragment : private fun updatePaginationLoader() { val loader = binding?.paginationLoader ?: return - val isLoadingNextPage = paginationState == GalleryPaginationState.LOADING && adapter?.isEmpty() == false - loader.setVisibleIf(isLoadingNextPage) + loader.setVisibleIf(isLoadingNextPage && adapter?.isEmpty() == false) } fun searchCompleted(result: GallerySearchTask.Result) { if (!isAdded) return - paginationState = when { - result.resultCode != RemoteOperationResult.ResultCode.OK -> GalleryPaginationState.FAILED - result.emptySearch -> GalleryPaginationState.COMPLETED - else -> GalleryPaginationState.IDLE - } + isLoadingNextPage = false if (result.resultCode == RemoteOperationResult.ResultCode.OUT_OF_MEMORY) { setEmptyListMessage(EmptyListState.OUT_OF_MEMORY) @@ -343,7 +339,7 @@ class GalleryFragment : private fun searchAndDisplayAfterChangingFolder() { // TODO: Fix folder change, it seems it doesn't work at all - paginationState = GalleryPaginationState.IDLE + isLoadingNextPage = false loadedItemCount = INITIAL_GALLERY_WINDOW restoreScrollPending = false clearSavedViewState() @@ -357,7 +353,7 @@ class GalleryFragment : return } - paginationState = GalleryPaginationState.LOADING + isLoadingNextPage = true photoSearchTask = GallerySearchTask( this, @@ -369,8 +365,8 @@ class GalleryFragment : } private fun loadMoreWhenEndReached(recyclerView: RecyclerView, dy: Int) { - if (dy <= 0 || paginationState != GalleryPaginationState.IDLE) { - // scrolling up, a page is already loading, or there is nothing left to load + if (dy <= 0 || isLoadingNextPage) { + // scrolling up or search query already active, do not search gallery return } @@ -415,7 +411,7 @@ class GalleryFragment : } override fun updateMediaContent(mediaState: MediaState) { - paginationState = GalleryPaginationState.IDLE + isLoadingNextPage = false loadedItemCount = INITIAL_GALLERY_WINDOW restoreScrollPending = false clearSavedViewState() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt deleted file mode 100644 index 55e0b27d4cef..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryPaginationState.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.owncloud.android.ui.fragment - -enum class GalleryPaginationState { - IDLE, - LOADING, - COMPLETED, - FAILED -} From 14fef753bdcf2a6de9e669fcb18c4c1eff080adc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 15:29:00 +0200 Subject: [PATCH 16/19] prevent unncessary updates Signed-off-by: alperozturk96 --- .../java/com/owncloud/android/ui/fragment/GalleryFragment.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 85b97a8ac430..44a46360eb63 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 @@ -76,6 +76,10 @@ class GalleryFragment : private var isLoadingNextPage = false set(value) { + if (field == value) { + return + } + field = value updatePaginationLoader() } From 010088dff56975236621b44b1e21e18eb48793aa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 10 Sep 2026 15:40:30 +0200 Subject: [PATCH 17/19] show loader smoothly Signed-off-by: alperozturk96 --- .../owncloud/android/ui/fragment/GalleryFragment.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 44a46360eb63..a2b6fe5ced4e 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 @@ -74,6 +74,8 @@ class GalleryFragment : private var bottomSheet: GalleryFragmentBottomSheetDialog? = null + private val showPaginationLoader = Runnable { binding?.paginationLoader.setVisibleIf(true) } + private var isLoadingNextPage = false set(value) { if (field == value) { @@ -155,6 +157,8 @@ class GalleryFragment : LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(refreshSearchEventReceiver) + binding?.paginationLoader?.removeCallbacks(showPaginationLoader) + adapter = null super.onDestroyView() @@ -292,7 +296,13 @@ class GalleryFragment : private fun updatePaginationLoader() { val loader = binding?.paginationLoader ?: return - loader.setVisibleIf(isLoadingNextPage && adapter?.isEmpty() == false) + loader.removeCallbacks(showPaginationLoader) + + if (isLoadingNextPage && adapter?.isEmpty() == false) { + loader.postDelayed(showPaginationLoader, PAGINATION_LOADER_DELAY_IN_MS) + } else { + loader.setVisibleIf(false) + } } fun searchCompleted(result: GallerySearchTask.Result) { @@ -510,6 +520,7 @@ class GalleryFragment : private const val MAX_ITEMS_PER_ROW = 10 private const val FRAGMENT_TAG_BOTTOM_SHEET = "data" private const val ITEM_VIEW_CACHE_SIZE = 8 + private const val PAGINATION_LOADER_DELAY_IN_MS = 500L private const val INITIAL_GALLERY_WINDOW = 500 private const val GALLERY_WINDOW_INCREMENT = 500 From d379ab97535b6a6012028f0b3f294dc335333f8f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 11 Sep 2026 12:08:49 +0200 Subject: [PATCH 18/19] fix video overlay Signed-off-by: alperozturk96 --- .../utils/extensions/ImageViewExtensions.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt index 4c35af4accca..5a8a0306f119 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ImageViewExtensions.kt @@ -11,6 +11,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.GradientDrawable import android.util.TypedValue +import android.view.Gravity import android.view.View import android.view.ViewOutlineProvider import android.widget.ImageView @@ -56,16 +57,23 @@ fun ImageView.setMediaThumbnail(file: OCFile, bitmap: Bitmap) { background = null } - foreground = if (MimeTypeUtil.isVideo(file)) { - ContextCompat.getDrawable(context, R.drawable.video_white) - } else { - null - } + applyVideoOverlay(file) setImageBitmap(bitmap) setTag(R.id.media_thumbnail_file_id, file.fileId) } +private fun ImageView.applyVideoOverlay(file: OCFile) { + if (!MimeTypeUtil.isVideo(file)) { + foreground = null + return + } + + // without a gravity the foreground is stretched over the whole cell, which distorts the icon + foregroundGravity = Gravity.CENTER + foreground = ContextCompat.getDrawable(context, R.drawable.video_white) +} + fun ImageView.showsMediaThumbnailOf(file: OCFile): Boolean = getTag(R.id.media_thumbnail_file_id) == file.fileId @JvmOverloads From f1f9cdeaf4a4400fcb8094d00e2cbdf2d1d4d758 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 11 Sep 2026 12:09:09 +0200 Subject: [PATCH 19/19] fix pager item selection Signed-off-by: alperozturk96 --- .../ui/preview/PreviewImageActivity.kt | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 465bbf594c42..8d0a69ecdc4c 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -162,7 +162,10 @@ class PreviewImageActivity : override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - initializeContent(intent) + // every later read goes through getIntent(), so the selection of the new intent would be lost otherwise + intent?.let { setIntent(it) } + + initializeContent() } override fun downloadFile(file: OCFile, packageName: String, activityName: String) { @@ -320,47 +323,61 @@ class PreviewImageActivity : public override fun onStart() { super.onStart() registerReceivers() - initializeContent(intent) + initializeContent() } - private fun initializeContent(newIntent: Intent?) { - val intent = newIntent ?: intent + private fun initializeContent() { + val optionalUser = user + if (!optionalUser.isPresent) { + finish() + return + } + livePhotoFile = intent.getParcelableArgument(EXTRA_LIVE_PHOTO_FILE, OCFile::class.java) val chosenFile = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + val requestedFile = chosenFile ?: file ?: throw IllegalStateException("Instanced with a NULL OCFile") + require(MimeTypeUtil.isImageOrVideo(requestedFile)) { "Non-image/video file passed as argument" } - val optionalUser = user - if (!optionalUser.isPresent) { + val currentFile = requestedFile.refreshedFromDatabase() + if (currentFile == null) { + // handled file not in the current Account finish() return } - var file: OCFile? = chosenFile ?: file ?: throw IllegalStateException("Instanced with a NULL OCFile") - // updateActionBarTitle(file?.fileName) - // / Validate handled file (first media item to preview) - require(MimeTypeUtil.isImageOrVideo(file)) { "Non-image/video file passed as argument" } + val isNewSelection = currentFile != file + + setFile(currentFile) + showFile(currentFile, optionalUser.get(), isNewSelection) + } + + private fun OCFile.refreshedFromDatabase(): OCFile? = if (fileId > FileDataStorageManager.ROOT_PARENT_ID) { + storageManager.getFileById(fileId) + } else { + this + } + + private fun showFile(file: OCFile, user: User, isNewSelection: Boolean) { + val position = previewMediaPagerAdapter?.getFilePosition(file) ?: NO_POSITION + val pagerNeedsRebuild = position == NO_POSITION - // Update file according to DB file, if it is possible - if (file!!.fileId > FileDataStorageManager.ROOT_PARENT_ID) { - file = storageManager.getFileById(file.fileId) + // a restart must not pull the user back to the file the activity was started with + if (!pagerNeedsRebuild && !isNewSelection) { + return } - if (file != null) { - // / Refresh the activity according to the Account and OCFile set - setFile(file) // reset after getting it fresh from storageManager - updateActionBarTitle(getFile()?.fileName) - if (previewMediaPagerAdapter == null || previewMediaPagerAdapter?.getFilePosition(file) == -1) { - savedPosition = null - initViewPager(optionalUser.get()) - } else { - previewMediaPagerAdapter?.getFilePosition(file)?.let { - viewPager?.currentItem = it - } - } - } else { - // handled file not in the current Account - finish() + updateActionBarTitle(file.fileName) + + if (pagerNeedsRebuild) { + savedPosition = null + initViewPager(user) + return } + + // a rebuilt pager restores the remembered position, so it has to follow the selection right away + savedPosition = position + viewPager?.setCurrentItem(position, false) } override fun onSaveInstanceState(outState: Bundle) {