From 9cfe0f471f2da5b561c51b36709876a21352eca8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 24 Sep 2026 10:41:04 +0200 Subject: [PATCH 1/4] add pinch listener for column change Signed-off-by: alperozturk96 --- .../android/ui/fragment/GalleryFragment.kt | 67 ++++++++++++++++++- .../android/ui/fragment/helper/ColumnCount.kt | 7 +- .../fragment/helper/GalleryPinchListener.kt | 39 +++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/helper/GalleryPinchListener.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 17f6054e6a61..db7873fcad83 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 @@ -21,6 +21,8 @@ import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MotionEvent +import android.view.ScaleGestureDetector import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts @@ -55,12 +57,13 @@ import com.owncloud.android.ui.asynctasks.GallerySearchTask import com.owncloud.android.ui.events.ChangeMenuEvent import com.owncloud.android.ui.fragment.GalleryFragmentBottomSheetDialog.MediaState import com.owncloud.android.ui.fragment.helper.ColumnCount +import com.owncloud.android.ui.fragment.helper.GalleryPinchListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@Suppress("ForbiddenComment", "ReturnCount", "MagicNumber", "MaxLineLength") +@Suppress("ForbiddenComment", "ReturnCount", "MagicNumber", "MaxLineLength", "TooManyFunctions") class GalleryFragment : OCFileListFragment(), GalleryFragmentBottomSheetActions { @@ -70,6 +73,7 @@ class GalleryFragment : private val limit = 150 private var loadedItemCount = INITIAL_GALLERY_WINDOW private var restoreScrollPending = false + private var scrollAnchorFile: OCFile? = null private var adapter: GalleryAdapter? = null private var bottomSheet: GalleryFragmentBottomSheetDialog? = null @@ -98,7 +102,7 @@ class GalleryFragment : isFromAlbum = it.getBoolean(AlbumsPickerActivity.EXTRA_FROM_ALBUM, false) } bottomSheet = GalleryFragmentBottomSheetDialog() - columnsCount = ColumnCount.Wide.get(resources.isLandscape()) + columnsCount = defaultColumnsCount(resources.isLandscape()) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -182,6 +186,8 @@ class GalleryFragment : } }) + setupPinchToChangeColumns() + Log_OC.i(this, "onCreateView() in GalleryFragment end") return v } @@ -240,7 +246,7 @@ class GalleryFragment : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - columnsCount = ColumnCount.Wide.get(newConfig.isLandscape()) + columnsCount = defaultColumnsCount(newConfig.isLandscape()) adapter?.changeColumn(columnsCount) showAllGalleryItems() } @@ -459,6 +465,7 @@ class GalleryFragment : } adapter?.updateList(galleryItems) updateSubtitle(mediaState) + scrollToAnchorFile() if (restoreScrollPending && galleryItems.isNotEmpty()) { restoreScrollPending = false @@ -512,12 +519,66 @@ class GalleryFragment : override fun setGridViewColumns(scaleFactor: Float) = Unit + private fun setupPinchToChangeColumns() { + val detector = ScaleGestureDetector(requireContext(), GalleryPinchListener(::changeColumnsBy)) + recyclerView?.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + detector.onTouchEvent(e) + return detector.isInProgress + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + detector.onTouchEvent(e) + } + }) + } + + private fun defaultColumnsCount(isLandscape: Boolean): Int = + if (isLandscape) ColumnCount.Wide.landscape else ColumnCount.Normal.portrait + + private fun changeColumnsBy(delta: Int) { + val maxColumns = ColumnCount.Wide.get(resources.isLandscape()) + val updated = (columnsCount + delta).coerceIn(MIN_COLUMNS, maxColumns) + if (updated == columnsCount) { + return + } + + columnsCount = updated + adapter?.changeColumn(columnsCount) + scrollAnchorFile = scrollAnchorFile ?: firstVisibleFile() + showAllGalleryItems() + } + + private fun firstVisibleFile(): OCFile? { + val layoutManager = recyclerView?.layoutManager as? GridLayoutManager ?: return null + val first = layoutManager.findFirstVisibleItemPosition() + val last = layoutManager.findLastVisibleItemPosition() + if (first == RecyclerView.NO_POSITION) { + return null + } + + return (first..last).firstNotNullOfOrNull { adapter?.getItem(it) } + } + + private fun scrollToAnchorFile() { + val file = scrollAnchorFile ?: return + scrollAnchorFile = null + + val position = adapter?.getItemPosition(file) ?: return + if (position < 0) { + return + } + + (recyclerView?.layoutManager as? GridLayoutManager)?.scrollToPositionWithOffset(position, 0) + } + fun markAsFavorite(remotePath: String, favorite: Boolean) { adapter?.markAsFavorite(remotePath, favorite) } companion object { private const val MAX_ITEMS_PER_ROW = 10 + private const val MIN_COLUMNS = 1 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 diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/helper/ColumnCount.kt b/app/src/main/java/com/owncloud/android/ui/fragment/helper/ColumnCount.kt index 3cb445a2945e..30d9a8ab3e93 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/helper/ColumnCount.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/helper/ColumnCount.kt @@ -9,11 +9,12 @@ package com.owncloud.android.ui.fragment.helper private const val WIDE_LANDSCAPE_COLUMNS = 5 private const val NORMAL_LANDSCAPE_COLUMNS = 4 -private const val PORTRAIT_COLUMNS = 2 +private const val WIDE_PORTRAIT_COLUMNS = 3 +private const val NORMAL_PORTRAIT_COLUMNS = 2 enum class ColumnCount(val landscape: Int, val portrait: Int) { - Wide(WIDE_LANDSCAPE_COLUMNS, PORTRAIT_COLUMNS), - Normal(NORMAL_LANDSCAPE_COLUMNS, PORTRAIT_COLUMNS); + Wide(WIDE_LANDSCAPE_COLUMNS, WIDE_PORTRAIT_COLUMNS), + Normal(NORMAL_LANDSCAPE_COLUMNS, NORMAL_PORTRAIT_COLUMNS); fun get(isLandscape: Boolean): Int = if (isLandscape) landscape else portrait } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/helper/GalleryPinchListener.kt b/app/src/main/java/com/owncloud/android/ui/fragment/helper/GalleryPinchListener.kt new file mode 100644 index 000000000000..fe0fb7055e6a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/helper/GalleryPinchListener.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.helper + +import android.view.ScaleGestureDetector + +private const val ZOOM_IN_THRESHOLD = 1.25f +private const val ZOOM_OUT_THRESHOLD = 0.8f +private const val NEUTRAL_SCALE = 1f + +class GalleryPinchListener(private val onColumnStep: (Int) -> Unit) : + ScaleGestureDetector.SimpleOnScaleGestureListener() { + + private var accumulatedScale = NEUTRAL_SCALE + + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + accumulatedScale = NEUTRAL_SCALE + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + accumulatedScale *= detector.scaleFactor + + if (accumulatedScale >= ZOOM_IN_THRESHOLD) { + accumulatedScale = NEUTRAL_SCALE + onColumnStep(-1) + } else if (accumulatedScale <= ZOOM_OUT_THRESHOLD) { + accumulatedScale = NEUTRAL_SCALE + onColumnStep(1) + } + + return true + } +} From f957c3a0e6b0bbcafe3851b7bd084e45e997c7c1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 24 Sep 2026 10:41:20 +0200 Subject: [PATCH 2/4] remove cache check since job already doing it Signed-off-by: alperozturk96 --- .../owncloud/android/ui/adapter/OCFileListDelegate.kt | 9 --------- 1 file changed, 9 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 095e258e3923..9c6367d017ae 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 @@ -18,10 +18,8 @@ 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.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.setVisibleIf import com.nextcloud.utils.extensions.showsMediaThumbnailOf import com.nextcloud.utils.extensions.stopShimmer @@ -121,13 +119,6 @@ class OCFileListDelegate( imageView.tag = file.fileId - val cached = file.takeUnless { it.isUpdateThumbnailNeeded }?.getBigThumbnail() - if (cached != null) { - imageView.setMediaThumbnail(file, cached) - imageView.stopShimmer(shimmer) - return - } - imageView.setMediaPlaceholder(file, placeholderInset) val job = ioScope.launch(start = CoroutineStart.LAZY) { From 8917ae3b6782400707c64cd24e92d35d08674142 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 24 Sep 2026 10:41:47 +0200 Subject: [PATCH 3/4] speed-up load from disk Signed-off-by: alperozturk96 --- .../client/jobs/gallery/GalleryImageGenerationJob.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 ecfedae885bd..f0daab60ba92 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 @@ -43,7 +43,9 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag companion object { private const val TAG = "GalleryImageGenerationJob" + private const val DISK_CACHE_READ_PERMITS = 2 private val semaphore = Semaphore(maxOf(3, Runtime.getRuntime().availableProcessors() / 2)) + private val diskCacheSemaphore = Semaphore(DISK_CACHE_READ_PERMITS) private val activeJobs = Collections.synchronizedMap(WeakHashMap()) fun cancelAllActiveJobs() { @@ -111,7 +113,7 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag } private suspend fun getBitmap(file: OCFile): Bitmap? = withContext(Dispatchers.IO) { - val cached = file.getBigThumbnail() + val cached = diskCacheSemaphore.withPermit { file.getBigThumbnail() } if (cached != null && !file.isUpdateThumbnailNeeded) { return@withContext cached } @@ -200,7 +202,7 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag ) = withContext(Dispatchers.Main) { val tagId = file.fileId.toString() - if (imageView.tag.toString() == tagId && imageView.isAttachedToWindow) { + if (imageView.tag.toString() == tagId) { imageView.setMediaThumbnail(file, bitmap) } From 9525c759b9dbe31aa84b8cebfa076ff0962e7b52 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 24 Sep 2026 12:04:21 +0200 Subject: [PATCH 4/4] use cached image without lock Signed-off-by: alperozturk96 --- .../client/jobs/gallery/GalleryImageGenerationJob.kt | 5 +---- .../android/datamodel/ThumbnailsCacheManager.java | 9 +++++++++ 2 files changed, 10 insertions(+), 4 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 f0daab60ba92..7f6eddc05223 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 @@ -16,7 +16,6 @@ import android.util.Size import android.view.WindowManager import android.widget.ImageView 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.isPNG @@ -43,9 +42,7 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag companion object { private const val TAG = "GalleryImageGenerationJob" - private const val DISK_CACHE_READ_PERMITS = 2 private val semaphore = Semaphore(maxOf(3, Runtime.getRuntime().availableProcessors() / 2)) - private val diskCacheSemaphore = Semaphore(DISK_CACHE_READ_PERMITS) private val activeJobs = Collections.synchronizedMap(WeakHashMap()) fun cancelAllActiveJobs() { @@ -113,7 +110,7 @@ class GalleryImageGenerationJob(private val user: User, private val storageManag } private suspend fun getBitmap(file: OCFile): Bitmap? = withContext(Dispatchers.IO) { - val cached = diskCacheSemaphore.withPermit { file.getBigThumbnail() } + val cached = ThumbnailsCacheManager.getBitmapFromDiskCacheWithoutLock(file.getBigThumbnailKey()) if (cached != null && !file.isUpdateThumbnailNeeded) { return@withContext cached } 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 431822d694c8..381c96529e6c 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -293,6 +293,15 @@ public static Bitmap getScaledBitmapFromDiskCache(String key, int width, int hei return null; } + public static Bitmap getBitmapFromDiskCacheWithoutLock(String key) { + DiskLruImageCache cache = mThumbnailCache; + if (cache == null) { + return null; + } + + return cache.getBitmap(key); + } + public static Bitmap getBitmapFromDiskCache(String key) { synchronized (mThumbnailsDiskCacheLock) { // Wait while disk cache is started from background thread