diff --git a/.gitignore b/.gitignore index 8011b59..230ed55 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ keystore.properties fastlane/fastlane.json fastlane/report.xml .kotlin/ - +.agents/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc848a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to AI Agents when working with code in this repository. + +## Project Overview + +Right Gallery is an Android gallery/photo viewer app (package: `com.goodwy.gallery`), forked from Simple Gallery. Written in Kotlin, it uses View Binding (not Compose), Room for local database, Glide for image loading, and ExoPlayer (Media3) for video playback. + +## Build Commands + +```bash +# Debug build (FOSS flavor) +./gradlew assembleFossDebug + +# Debug build (Google Play flavor) +./gradlew assembleGplayDebug + +# Release build +./gradlew assembleFossRelease +./gradlew assembleGplayRelease + +# Lint check +./gradlew lint + +# Detekt (static analysis) +./gradlew detekt +``` + +## Build Configuration + +- **Min SDK**: 26, **Target SDK**: 34, **Compile SDK**: 35 +- **Java/Kotlin target**: JVM 17 +- **Product flavors**: `foss` and `gplay` (licensing dimension) — flavor-specific resources live in `app/src/foss/` and `app/src/gplay/` +- **Signing**: via `keystore.properties` file or `SIGNING_*` environment variables +- **In-app product IDs**: configured in `local.properties` (PRODUCT_ID_X1..X4, SUBSCRIPTION_ID_X1..X3, etc.) +- **Version info**: managed in `gradle.properties` (VERSION_NAME, VERSION_CODE, APP_ID) +- **Dependencies catalog**: `gradle/libs.versions.toml` + +## Architecture + +### Core dependency +The app depends heavily on `com.github.Goodwy:Goodwy-Commons` (referenced as `goodwy.commons`), which provides base activities, shared utilities, common UI components, and the commons configuration system. Many base classes (activities, helpers) come from this library. + +### Key packages under `app/src/main/kotlin/com/goodwy/gallery/` + +- **activities/**: All Activity classes. `SimpleActivity` is the base for most activities. Key activities: `MainActivity` (directory listing), `MediaActivity` (media grid within a folder), `ViewPagerActivity` (full-screen media viewer), `EditActivity` (image editor), `VideoPlayerActivity` +- **adapters/**: RecyclerView adapters — `DirectoryAdapter` (folder list), `MediaAdapter` (media grid), with custom binding helpers (`DirectoryItemBinding`, `MediaItemBinding`) +- **models/**: Room entities and data classes — `Directory`, `Medium`, `Favorite`, `DateTaken`, `Widget`, plus editor models (`CanvasOp`, `PaintOptions`, `MyPath`) +- **databases/**: `GalleryDatabase` — Room database (version 10) with DAOs for directories, media, widgets, date_takens, and favorites +- **interfaces/**: Room DAO interfaces (`DirectoryDao`, `MediumDao`, `FavoritesDao`, `DateTakensDao`, `WidgetsDao`) and listener interfaces +- **helpers/**: `Config` (SharedPreferences wrapper for app settings), `Constants` (shared constant values), `MediaFetcher` (scans filesystem for media files), image loading helpers +- **extensions/**: Kotlin extension functions on `Context`, `Activity`, `String`, `View`, etc. +- **fragments/**: `PhotoFragment` and `VideoFragment` for the ViewPager-based media viewer +- **views/**: Custom views — `EditorDrawCanvas`, `MediaSideScroll` (brightness/volume gesture), `InstantItemSwitch` +- **receivers/**: `BootCompletedReceiver`, `RefreshMediaReceiver` +- **jobs/**: `NewPhotoFetcher` (background job to detect new photos) + +### Data flow +Media discovery flows through `MediaFetcher` → cached in Room (`MediumDao`/`DirectoryDao`) → displayed via adapters. The `Config` helper wraps SharedPreferences for all user settings (sort order, view type, grouping, etc.). diff --git a/app/src/main/kotlin/com/goodwy/gallery/App.kt b/app/src/main/kotlin/com/goodwy/gallery/App.kt index d6c6fd8..b56fc8f 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/App.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/App.kt @@ -1,5 +1,8 @@ package com.goodwy.gallery +import android.content.ComponentCallbacks2 +import com.bumptech.glide.Glide +import com.bumptech.glide.MemoryCategory import com.github.ajalt.reprint.core.Reprint import com.goodwy.commons.RightApp import com.goodwy.commons.extensions.isRuStoreInstalled @@ -23,4 +26,17 @@ class App : RightApp() { override fun shutdown() {} }).build()) } + + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + Glide.get(this).setMemoryCategory(MemoryCategory.LOW) + } + Glide.get(this).trimMemory(level) + } + + override fun onLowMemory() { + super.onLowMemory() + Glide.get(this).clearMemory() + } } diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/EditActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/EditActivity.kt index 145416c..23cab38 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/EditActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/EditActivity.kt @@ -47,6 +47,7 @@ import com.goodwy.gallery.extensions.getCompressionFormatFromUri import com.goodwy.gallery.extensions.readExif import com.goodwy.gallery.extensions.proposeNewFilePath import com.goodwy.gallery.extensions.resolveUriScheme +import com.goodwy.gallery.extensions.rescanFolderMediaSync import com.goodwy.gallery.extensions.showContentDescriptionOnLongClick import com.goodwy.gallery.extensions.writeBitmapToCache import com.goodwy.gallery.extensions.fixDateTaken @@ -1017,13 +1018,67 @@ class EditActivity : SimpleActivity(), CanvasListener { } } - private fun finishCropResultForContent(uri: Uri) { - val result = Intent().apply { - data = uri - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + private fun getPathFromUri(uri: Uri?): String? { + if (uri == null) { + return null + } + + return when (uri.scheme) { + "file" -> uri.path + else -> getRealPathFromURI(uri) + }?.takeIf { it.isNotEmpty() } + } + + private fun getFoldersToResync(savedPaths: Collection): ArrayList { + val folders = linkedSetOf() + + savedPaths.map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { path -> + folders.add(path.getParentPath()) + } + + arrayOf(uri, saveUri, originalUri).forEach { currentUri -> + getPathFromUri(currentUri)?.getParentPath()?.let(folders::add) + } + + return folders.filter { it.isNotEmpty() } as ArrayList + } + + private fun finalizeEditedMedia( + savedPaths: Collection, + resultIntent: Intent, + completion: () -> Unit, + ) { + val pathsToScan = savedPaths.map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() as ArrayList + val foldersToResync = getFoldersToResync(pathsToScan) + + val completeOnUi = { + runOnUiThread { + setResult(RESULT_OK, resultIntent) + completion() + } + } + + val resyncFolders = { + ensureBackgroundThread { + foldersToResync.forEach { folderPath -> + applicationContext.rescanFolderMediaSync(folderPath) + } + completeOnUi() + } + } + + if (pathsToScan.isNotEmpty()) { + rescanPaths(pathsToScan) { + fixDateTaken(pathsToScan, false) + resyncFolders() + } + } else { + resyncFolders() } - setResult(RESULT_OK, result) - finish() } private fun freeMemory() { @@ -1087,8 +1142,10 @@ class EditActivity : SimpleActivity(), CanvasListener { } writeExif(oldExif, file.toUri()) - setResult(RESULT_OK, intent) - scanFinalPath(file.absolutePath) + finalizeEditedMedia(arrayListOf(file.absolutePath), intent) { + toast(com.goodwy.commons.R.string.file_saved) + finish() + } } private fun saveBitmapToContentUri( @@ -1121,11 +1178,21 @@ class EditActivity : SimpleActivity(), CanvasListener { out.flush() writeExif(oldExif, uri) - runOnUiThread { + val realPath = getPathFromUri(uri) + val savedPaths = listOfNotNull(realPath) + val resultIntent = if (isCropCommit) { + Intent().apply { + data = uri + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } else { + intent + } + + finalizeEditedMedia(savedPaths, resultIntent) { if (isCropCommit) { - finishCropResultForContent(uri) + finish() } else { - setResult(RESULT_OK, intent) toast(com.goodwy.commons.R.string.file_saved) finish() } @@ -1143,16 +1210,6 @@ class EditActivity : SimpleActivity(), CanvasListener { isEditingWithThirdParty = true } - private fun scanFinalPath(path: String) { - val paths = arrayListOf(path) - rescanPaths(paths) { - fixDateTaken(paths, false) - setResult(RESULT_OK, intent) - toast(com.goodwy.commons.R.string.file_saved) - finish() - } - } - override fun toggleUndoVisibility(visible: Boolean) { //bottom_draw_undo.beVisibleIf(visible) binding.editorToolbar.menu.findItem(R.id.undo).isEnabled = visible diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/ExcludedFoldersActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/ExcludedFoldersActivity.kt index 51f8b52..01f00be 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/ExcludedFoldersActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/ExcludedFoldersActivity.kt @@ -10,6 +10,7 @@ import com.goodwy.gallery.R import com.goodwy.gallery.adapters.ManageFoldersAdapter import com.goodwy.gallery.databinding.ActivityManageFoldersBinding import com.goodwy.gallery.extensions.config +import com.goodwy.gallery.extensions.evictFoldersFromCache class ExcludedFoldersActivity : SimpleActivity(), RefreshRecyclerViewListener { @@ -77,6 +78,7 @@ class ExcludedFoldersActivity : SimpleActivity(), RefreshRecyclerViewListener { ) { config.lastFilepickerPath = it config.addExcludedFolder(it) + applicationContext.evictFoldersFromCache(listOf(it)) updateFolders() } } diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/MainActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/MainActivity.kt index d9ef505..7c8a1e9 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/MainActivity.kt @@ -554,6 +554,7 @@ class MainActivity : SimpleActivity(), DirectoryOperationsListener { val otgPath = trimEnd('/') config.OTGPath = otgPath config.addIncludedFolder(otgPath) + applicationContext.warmIncludedFolderCaches(otgPath) } } } @@ -1727,6 +1728,35 @@ class MainActivity : SimpleActivity(), DirectoryOperationsListener { } } + override fun excludeDirectories(paths: Set) { + val normalizedPaths = paths.map { it.trimEnd('/') } + .filter { it.isNotEmpty() } + .toSet() + + if (normalizedPaths.isEmpty()) { + return + } + + val shouldRemove: (Directory) -> Boolean = { directory -> + normalizedPaths.any { excludedPath -> + directory.path.equals(excludedPath, true) || directory.path.startsWith("$excludedPath/", true) + } + } + + mDirs = mDirs.filterNot(shouldRemove) as ArrayList + mDirsIgnoringSearch = mDirsIgnoringSearch.filterNot(shouldRemove) as ArrayList + + getRecyclerAdapter()?.let { adapter -> + val updatedDirs = adapter.dirs.filterNot(shouldRemove) as ArrayList + runOnUiThread { + adapter.updateDirs(updatedDirs) + checkPlaceholderVisibility(updatedDirs) + } + } + + applicationContext.evictFoldersFromCache(normalizedPaths) + } + private fun checkWhatsNewDialog() { arrayListOf().apply { add(Release(504, R.string.release_504)) diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/MediaActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/MediaActivity.kt index 3958171..4a7408c 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/MediaActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/MediaActivity.kt @@ -8,6 +8,7 @@ import android.graphics.Bitmap import android.graphics.Color import android.os.Bundle import android.os.Handler +import android.os.Looper import android.speech.RecognizerIntent import android.view.View import android.view.ViewGroup @@ -64,8 +65,8 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { private var mLastSearchedText = "" private var mLatestMediaId = 0L private var mLatestMediaDateId = 0L - private var mLastMediaHandler = Handler() - private var mTempShowHiddenHandler = Handler() + private var mLastMediaHandler = Handler(Looper.getMainLooper()) + private var mTempShowHiddenHandler = Handler(Looper.getMainLooper()) private var mCurrAsyncTask: GetMediaAsynctask? = null private var mZoomListener: MyRecyclerView.MyZoomListener? = null @@ -138,6 +139,7 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { } updateWidgets() + maybeRunMediaDbMaintenance() setupTabs() } @@ -512,11 +514,6 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { // } // } - if (mShowLoadingIndicator) { - binding.loadingIndicator.show() - mShowLoadingIndicator = false - } - binding.mediaMenu.updateTitle(if (mShowAll) resources.getString(com.goodwy.strings.R.string.library) else dirName) getMedia() setupLayoutManager() @@ -540,7 +537,7 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { isAGetIntent = mIsGetImageIntent || mIsGetVideoIntent || mIsGetAnyIntent, allowMultiplePicks = mAllowPickingMultiple, path = mPath, - recyclerView = binding.mediaGrid, + mediaRecyclerView = binding.mediaGrid, swipeRefreshLayout = binding.mediaRefreshLayout ) { if (it is Medium && !isFinishing) { @@ -763,6 +760,11 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { if (mLoadedInitialPhotos) { startAsyncTask() } else { + // Show spinner optimistically; hide immediately if cache has data + if (mShowLoadingIndicator) { + binding.loadingIndicator.show() + mShowLoadingIndicator = false + } getCachedMedia( mPath, mIsGetVideoIntent && !mIsGetImageIntent, @@ -1097,7 +1099,7 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { private fun gotMedia(media: ArrayList, isFromCache: Boolean) { mIsGettingMedia = false - checkLastMediaChanged() + if (!isFromCache) checkLastMediaChanged() mMedia = media runOnUiThread { @@ -1110,7 +1112,7 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { binding.mediaEmptyTextPlaceholder.text = getString(R.string.no_media_with_filters) } binding.mediaFastscroller.beVisibleIf(binding.mediaEmptyTextPlaceholder.isGone()) - if (!isFromCache) setupAdapter() + setupAdapter() } mLatestMediaId = getLatestMediaId() @@ -1236,10 +1238,10 @@ class MediaActivity : SimpleActivity(), MediaOperationsListener { private fun setupTabsColor() { val tabBackground = when { isDynamicTheme() && !isSystemInDarkMode() -> getProperBackgroundColor() - isLightTheme() -> resources.getColor(R.color.tab_background_light) - isGrayTheme() -> resources.getColor(R.color.tab_background_gray) - isDarkTheme() -> resources.getColor(R.color.tab_background_dark) - isBlackTheme() -> resources.getColor(R.color.tab_background_black) + isLightTheme() -> androidx.core.content.ContextCompat.getColor(this, R.color.tab_background_light) + isGrayTheme() -> androidx.core.content.ContextCompat.getColor(this, R.color.tab_background_gray) + isDarkTheme() -> androidx.core.content.ContextCompat.getColor(this, R.color.tab_background_dark) + isBlackTheme() -> androidx.core.content.ContextCompat.getColor(this, R.color.tab_background_black) else -> getSurfaceColor().adjustAlpha(0.95f) } binding.mainTopTabsBackground.backgroundTintList = ColorStateList.valueOf(tabBackground) diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/SettingsActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/SettingsActivity.kt index b99d1a3..4a77334 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/SettingsActivity.kt @@ -173,6 +173,7 @@ class SettingsActivity : SimpleActivity() { setupMaxBrightness() setupCropThumbnails() setupAnimateGifs() + setupThumbnailCacheSize() setupScrollHorizontally() setupEnablePullToRefresh() @@ -589,6 +590,26 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupThumbnailCacheSize() { + binding.settingsThumbnailCacheSize.text = getThumbnailCacheSizeText() + binding.settingsThumbnailCacheSizeHolder.setOnClickListener { + val items = arrayListOf( + RadioItem(100, "100 MB"), + RadioItem(256, "256 MB"), + RadioItem(512, "512 MB"), + RadioItem(1024, "1024 MB"), + RadioItem(2048, "2048 MB") + ) + RadioGroupDialog(this@SettingsActivity, items, config.diskCacheSizeMB, R.string.thumbnail_cache_size) { + config.diskCacheSizeMB = it as Int + binding.settingsThumbnailCacheSize.text = getThumbnailCacheSizeText() + toast(R.string.thumbnail_cache_size_restart) + } + } + } + + private fun getThumbnailCacheSizeText() = "${config.diskCacheSizeMB} MB" + private fun setupDarkBackground() { binding.settingsBlackBackground.isChecked = config.blackBackground binding.settingsBlackBackgroundHolder.setOnClickListener { diff --git a/app/src/main/kotlin/com/goodwy/gallery/activities/SimpleActivity.kt b/app/src/main/kotlin/com/goodwy/gallery/activities/SimpleActivity.kt index 5c2a956..50cd2cb 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/activities/SimpleActivity.kt @@ -6,6 +6,8 @@ import android.provider.MediaStore.Images import android.provider.MediaStore.Video import android.view.WindowManager import androidx.appcompat.app.AlertDialog +import com.bumptech.glide.Glide +import com.bumptech.glide.MemoryCategory import com.goodwy.commons.activities.BaseSimpleActivity import com.goodwy.commons.dialogs.FilePickerDialog import com.goodwy.commons.extensions.* @@ -16,12 +18,18 @@ import com.goodwy.gallery.dialogs.StoragePermissionRequiredDialog import com.goodwy.gallery.extensions.addPathToDB import com.goodwy.gallery.extensions.config import com.goodwy.gallery.extensions.updateDirectoryPath +import com.goodwy.gallery.extensions.warmIncludedFolderCaches import com.goodwy.gallery.helpers.getPermissionsToRequest open class SimpleActivity : BaseSimpleActivity() { private var dialog: AlertDialog? = null + override fun onResume() { + super.onResume() + Glide.get(this).setMemoryCategory(MemoryCategory.NORMAL) + } + private val observer = object : ContentObserver(null) { override fun onChange(selfChange: Boolean, uri: Uri?) { super.onChange(selfChange, uri) @@ -90,6 +98,7 @@ open class SimpleActivity : BaseSimpleActivity() { callback() ensureBackgroundThread { scanPathRecursively(it) + applicationContext.warmIncludedFolderCaches(it) } } } diff --git a/app/src/main/kotlin/com/goodwy/gallery/adapters/DirectoryAdapter.kt b/app/src/main/kotlin/com/goodwy/gallery/adapters/DirectoryAdapter.kt index a546326..a164e2b 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/adapters/DirectoryAdapter.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/adapters/DirectoryAdapter.kt @@ -18,7 +18,9 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.Target import com.google.gson.Gson import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import com.goodwy.commons.activities.BaseSimpleActivity @@ -71,6 +73,10 @@ class DirectoryAdapter( private var lockedFolderPaths = ArrayList() private var isDragAndDropping = false private var startReorderDragListener: StartReorderDragListener? = null + private val preloadTargets: MutableList> = mutableListOf() + private val prefetchItemBudget = 20 + private var directoryRecyclerView: RecyclerView? = null + private var cachedThumbnailSize = 0 private var showMediaCount = config.showFolderMediaCount private var folderStyle = config.folderStyle @@ -432,23 +438,50 @@ class DirectoryAdapter( if (selectedPaths.contains(RECYCLE_BIN)) { config.showRecycleBinAtFolders = false if (selectedPaths.size == 1) { + removeExcludedDirsFromView() listener?.refreshItems() - finishActMode() } } if (paths.size == 1) { - ExcludeFolderDialog(activity, paths.toMutableList()) { + ExcludeFolderDialog(activity, paths.toMutableList()) { excludedPath -> + listener?.excludeDirectories(setOf(excludedPath)) + removeExcludedDirsFromView() listener?.refreshItems() - finishActMode() } } else if (paths.size > 1) { config.addExcludedFolders(paths) + listener?.excludeDirectories(paths) + removeExcludedDirsFromView() listener?.refreshItems() - finishActMode() } } + private fun removeExcludedDirsFromView() { + val excludedPaths = config.excludedFolders + val indicesToRemove = dirs.indices.filter { index -> + val dir = dirs.getOrNull(index) as? Directory ?: return@filter false + excludedPaths.any { excluded -> dir.path.equals(excluded, true) || dir.path.startsWith("$excluded/", true) } + } + + if (indicesToRemove.isNotEmpty()) { + val newDirs = dirs.filterIndexed { index, _ -> index !in indicesToRemove.toSet() } as ArrayList + syncRemovedDirectories(newDirs, indicesToRemove) + } + finishActMode() + } + + private fun syncRemovedDirectories(newDirs: ArrayList, removedPositions: List) { + removedPositions.sortedDescending().forEach { notifyItemRemoved(it) } + currentDirectoriesHash = newDirs.hashCode() + dirs = newDirs + keyToPositionCache.clear() + dirs.forEachIndexed { index, item -> + keyToPositionCache[item.path.hashCode()] = index + } + listener?.updateDirectories(newDirs) + } + private fun tryLockFolder() { if (config.wasFolderLockingNoticeShown) { lockFolder() @@ -578,7 +611,7 @@ class DirectoryAdapter( if (manager.isRequestPinShortcutSupported) { val dir = getFirstSelectedItem() ?: return val path = dir.path - val drawable = resources.getDrawable(R.drawable.shortcut_image).mutate() + val drawable = androidx.core.content.ContextCompat.getDrawable(activity, R.drawable.shortcut_image)!!.mutate() val coverThumbnail = config.parseAlbumCovers().firstOrNull { it.tmb == dir.path }?.tmb ?: dir.tmb activity.getShortcutImage(coverThumbnail, drawable) { val intent = Intent(activity, MediaActivity::class.java) @@ -767,7 +800,8 @@ class DirectoryAdapter( dirs = directories fillLockedFolders() notifyDataSetChanged() - finishActMode() + clearPrefetchRequests() + prefetchDirectoryThumbnails() } keyToPositionCache.clear() newDirs.forEachIndexed { index, item -> @@ -855,6 +889,9 @@ class DirectoryAdapter( cropThumbnails = cropThumbnails, roundCorners = roundedCorners, signature = directory.getKey(), + highPriority = true, + loadHighPriority = isHighPriorityPosition(holder.bindingAdapterPosition), + thumbnailSize = getDirectoryThumbnailSize(dirThumbnail), onError = { dirThumbnail.scaleType = ImageView.ScaleType.CENTER dirThumbnail.setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_vector_warning_colored)) @@ -941,6 +978,94 @@ class DirectoryAdapter( notifyItemMoved(fromPosition, toPosition) } + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + directoryRecyclerView = recyclerView + prefetchDirectoryThumbnails() + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + clearPrefetchRequests() + directoryRecyclerView = null + } + + private fun clearPrefetchRequests() { + preloadTargets.forEach { Glide.with(activity).clear(it) } + preloadTargets.clear() + } + + private fun prefetchDirectoryThumbnails() { + if (activity.isDestroyed || dirs.isEmpty()) { + return + } + + val firstVisible = (directoryRecyclerView?.layoutManager as? LinearLayoutManager) + ?.findFirstVisibleItemPosition()?.takeIf { it != RecyclerView.NO_POSITION } ?: 0 + val startIndex = maxOf(0, firstVisible - prefetchItemBudget / 4) + val endIndex = minOf(dirs.lastIndex, firstVisible + prefetchItemBudget) + val thumbnailSize = getPrefetchThumbnailSize() + var itemCount = 0 + for (index in startIndex..endIndex) { + if (itemCount >= prefetchItemBudget) break + val dir = dirs[index] + if (lockedFolderPaths.contains(dir.path)) continue + val roundedCorners = when { + isListViewType -> ROUNDED_CORNERS_SMALL + folderStyle == FOLDER_STYLE_SQUARE -> ROUNDED_CORNERS_NONE + else -> ROUNDED_CORNERS_SMALL + } + val target = activity.preloadImageBase( + path = dir.tmb, + cropThumbnails = cropThumbnails, + roundCorners = roundedCorners, + signature = dir.getKey(), + highPriority = true, + thumbnailSize = thumbnailSize + ) + preloadTargets.add(target) + itemCount++ + } + } + + private fun getDirectoryThumbnailSize(target: ImageView): Int { + val targetSize = maxOf(target.width, target.height) + if (targetSize > 0) { + cachedThumbnailSize = targetSize + } + + if (cachedThumbnailSize <= 0) { + cachedThumbnailSize = 512 + } + + return cachedThumbnailSize + } + + private fun getPrefetchThumbnailSize(): Int { + val sampleChild = directoryRecyclerView?.getChildAt(0) + val sampleThumbnail = sampleChild?.let { bindItem(it).dirThumbnail } + val thumbnailSize = maxOf(sampleThumbnail?.width ?: 0, sampleThumbnail?.height ?: 0) + if (thumbnailSize > 0) { + cachedThumbnailSize = thumbnailSize + } + + if (cachedThumbnailSize <= 0) { + cachedThumbnailSize = 512 + } + + return cachedThumbnailSize + } + + private fun isHighPriorityPosition(position: Int): Boolean { + if (position == RecyclerView.NO_POSITION) { + return true + } + + val layoutManager = directoryRecyclerView?.layoutManager as? LinearLayoutManager + val firstVisible = layoutManager?.findFirstVisibleItemPosition()?.takeIf { it != RecyclerView.NO_POSITION } ?: 0 + return position in firstVisible..(firstVisible + prefetchItemBudget / 2) + } + override fun onRowSelected(myViewHolder: ViewHolder?) { swipeRefreshLayout?.isEnabled = false } diff --git a/app/src/main/kotlin/com/goodwy/gallery/adapters/MediaAdapter.kt b/app/src/main/kotlin/com/goodwy/gallery/adapters/MediaAdapter.kt index 530c5d6..b679928 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/adapters/MediaAdapter.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/adapters/MediaAdapter.kt @@ -12,9 +12,11 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.allViews +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.Target import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import com.goodwy.commons.activities.BaseSimpleActivity import com.goodwy.commons.adapters.MyRecyclerViewAdapter @@ -44,10 +46,10 @@ class MediaAdapter( val isAGetIntent: Boolean, val allowMultiplePicks: Boolean, val path: String, - recyclerView: MyRecyclerView, + private val mediaRecyclerView: MyRecyclerView, val swipeRefreshLayout: SwipeRefreshLayout? = null, itemClick: (Any) -> Unit -) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), ItemTouchHelperContract, +) : MyRecyclerViewAdapter(activity, mediaRecyclerView, itemClick), ItemTouchHelperContract, RecyclerViewFastScroller.OnPopupTextUpdate { private val ITEM_SECTION = 0 @@ -73,6 +75,15 @@ class MediaAdapter( var actModeCallbacks = actModeCallback private val keyToPositionCache = mutableMapOf() + private val preloadTargets = mutableListOf>() + + private val highPriorityLookAheadItems = 12 + private val prefetchItemBudget = 48 + private val prefetchSizeBudgetBytes = 24L * 1024L * 1024L + private var cachedThumbnailSize = 0 + private val selectionKeyByPath = mutableMapOf() + private val pathBySelectionKey = mutableMapOf() + private var nextSelectionKey = 1 init { setupDragListener(true) @@ -106,7 +117,7 @@ class MediaAdapter( val allowLongPress = (!isAGetIntent || allowMultiplePicks) && tmbItem is Medium holder.bindView(tmbItem, tmbItem is Medium, allowLongPress) { itemView, adapterPosition -> if (tmbItem is Medium) { - setupThumbnail(itemView, tmbItem) + setupThumbnail(itemView, tmbItem, adapterPosition) } else { setupSection(itemView, tmbItem as ThumbnailSection, position) } @@ -187,11 +198,12 @@ class MediaAdapter( override fun getIsItemSelectable(position: Int) = !isASectionTitle(position) - override fun getItemSelectionKey(position: Int) = (media.getOrNull(position) as? Medium)?.path?.hashCode() + override fun getItemSelectionKey(position: Int) = (media.getOrNull(position) as? Medium)?.path?.let(::getOrCreateSelectionKey) // override fun getItemKeyPosition(key: Int) = media.indexOfFirst { (it as? Medium)?.path?.hashCode() == key } override fun getItemKeyPosition(key: Int): Int { - return keyToPositionCache[key] ?: media.indexOfFirst { (it as? Medium)?.path?.hashCode() == key } + val path = pathBySelectionKey[key] + return keyToPositionCache[key] ?: media.indexOfFirst { (it as? Medium)?.path == path } } override fun onActionModeCreated() { @@ -206,14 +218,23 @@ class MediaAdapter( override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) if (!activity.isDestroyed) { - val itemView = holder.itemView - val tmb = itemView.allViews.firstOrNull { it.id == R.id.medium_thumbnail } + val tmb = holder.itemView.findViewById(R.id.medium_thumbnail) if (tmb != null) { Glide.with(activity).clear(tmb) } } } + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + clearPrefetchRequests() + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + prefetchVisibleRangeThumbnails() + } + fun isASectionTitle(position: Int) = media.getOrNull(position) is ThumbnailSection private fun checkHideBtnVisibility(menu: Menu, selectedItems: ArrayList) { @@ -454,7 +475,7 @@ class MediaAdapter( val manager = activity.getSystemService(ShortcutManager::class.java) if (manager.isRequestPinShortcutSupported) { val path = getSelectedPaths().first() - val drawable = resources.getDrawable(R.drawable.shortcut_image).mutate() + val drawable = androidx.core.content.ContextCompat.getDrawable(activity, R.drawable.shortcut_image)!!.mutate() activity.getShortcutImage(path, drawable) { val intent = Intent(activity, ViewPagerActivity::class.java).apply { putExtra(PATH, path) @@ -582,26 +603,29 @@ class MediaAdapter( // private fun getItemWithKey(key: Int): Medium? = media.firstOrNull { (it as? Medium)?.path?.hashCode() == key } as? Medium // Fix: at kotlin.collections.CollectionsKt___CollectionsKt.firstOrNull (CollectionsKt___Collections.kt:295) private fun getItemWithKey(key: Int): Medium? { + val path = pathBySelectionKey[key] ?: return null return media.asSequence() .filterIsInstance() - .firstOrNull { it.path.hashCode() == key } + .firstOrNull { it.path == path } } @SuppressLint("NotifyDataSetChanged") fun updateMedia(newMedia: ArrayList) { val thumbnailItems = newMedia.clone() as ArrayList + clearPrefetchRequests() if (thumbnailItems.hashCode() != currentMediaHash) { currentMediaHash = thumbnailItems.hashCode() media = thumbnailItems notifyDataSetChanged() - finishActMode() } + rebuildSelectionKeyCache() keyToPositionCache.clear() newMedia.forEachIndexed { index, item -> if (item is Medium) { - keyToPositionCache[item.path.hashCode()] = index + keyToPositionCache[getOrCreateSelectionKey(item.path)] = index } } + prefetchVisibleRangeThumbnails() } @SuppressLint("NotifyDataSetChanged") @@ -625,8 +649,9 @@ class MediaAdapter( notifyDataSetChanged() } - private fun setupThumbnail(view: View, medium: Medium) { - val isSelected = selectedKeys.contains(medium.path.hashCode()) + private fun setupThumbnail(view: View, medium: Medium, adapterPosition: Int) { + val selectionKey = getOrCreateSelectionKey(medium.path) + val isSelected = selectedKeys.contains(selectionKey) bindItem(view, medium).apply { val padding = if (config.thumbnailSpacing <= 1) { config.thumbnailSpacing @@ -719,7 +744,9 @@ class MediaAdapter( cropThumbnails = cropThumbnails, roundCorners = roundedCorners, signature = medium.getKey(), + highPriority = isHighPriorityPosition(adapterPosition), skipMemoryCacheAtPaths = rotatedImagePaths, + thumbnailSize = cachedThumbnailSize, onError = { mediumThumbnail.scaleType = ImageView.ScaleType.CENTER mediumThumbnail.setImageDrawable(AppCompatResources.getDrawable(activity, R.drawable.ic_vector_warning_colored)) @@ -733,6 +760,110 @@ class MediaAdapter( } } + private fun isHighPriorityPosition(position: Int): Boolean { + val layoutManager = mediaRecyclerView.layoutManager as? LinearLayoutManager + val firstVisible = layoutManager?.findFirstVisibleItemPosition()?.takeIf { it != RecyclerView.NO_POSITION } ?: 0 + return position in firstVisible..(firstVisible + highPriorityLookAheadItems) + } + + private fun prefetchVisibleRangeThumbnails() { + if (activity.isDestroyed || media.isEmpty()) { + return + } + + clearPrefetchRequests() + + val roundedCorners = when { + isListViewType -> ROUNDED_CORNERS_SMALL + config.fileRoundedCorners -> ROUNDED_CORNERS_BIG + else -> ROUNDED_CORNERS_NONE + } + + var prefetchedItems = 0 + var prefetchedSizeBytes = 0L + + val sampleChild = mediaRecyclerView.getChildAt(0) + val thumbnailWidth = sampleChild?.width?.takeIf { it > 0 } ?: 300 + val thumbnailHeight = sampleChild?.height?.takeIf { it > 0 } ?: 300 + cachedThumbnailSize = maxOf(thumbnailWidth, thumbnailHeight) + val estimatedBitmapBytesPerItem = thumbnailWidth.toLong() * thumbnailHeight.toLong() * 4L + + val layoutManager = mediaRecyclerView.layoutManager as? LinearLayoutManager + val firstVisible = layoutManager?.findFirstVisibleItemPosition()?.takeIf { it != RecyclerView.NO_POSITION } ?: 0 + val startIndex = maxOf(0, firstVisible - prefetchItemBudget / 4) + val endIndex = minOf(media.lastIndex, firstVisible + prefetchItemBudget) + + for (index in startIndex..endIndex) { + if (prefetchedItems >= prefetchItemBudget || prefetchedSizeBytes >= prefetchSizeBudgetBytes) { + break + } + + val medium = media[index] as? Medium ?: continue + + if (prefetchedItems > 0 && prefetchedSizeBytes + estimatedBitmapBytesPerItem > prefetchSizeBudgetBytes) { + break + } + + var pathToLoad = medium.path + if (hasOTGConnected && activity.isPathOnOTG(pathToLoad)) { + pathToLoad = pathToLoad.getOTGPublicPath(activity) + } + + val requestTarget = activity.preloadImageBase( + path = pathToLoad, + cropThumbnails = cropThumbnails, + roundCorners = roundedCorners, + signature = medium.getKey(), + skipMemoryCache = rotatedImagePaths.contains(medium.path), + thumbnailSize = thumbnailWidth + ) + + preloadTargets.add(requestTarget) + prefetchedItems++ + prefetchedSizeBytes += estimatedBitmapBytesPerItem + } + } + + private fun clearPrefetchRequests() { + if (preloadTargets.isEmpty() || activity.isDestroyed) { + preloadTargets.clear() + return + } + + preloadTargets.forEach { Glide.with(activity).clear(it) } + preloadTargets.clear() + } + + private fun getOrCreateSelectionKey(path: String): Int { + selectionKeyByPath[path]?.let { return it } + + while (nextSelectionKey == -1 || pathBySelectionKey.containsKey(nextSelectionKey)) { + nextSelectionKey++ + } + + val selectionKey = nextSelectionKey++ + selectionKeyByPath[path] = selectionKey + pathBySelectionKey[selectionKey] = path + return selectionKey + } + + private fun rebuildSelectionKeyCache() { + val currentPaths = media.filterIsInstance().mapTo(HashSet()) { it.path } + val selectedPaths = selectedKeys.mapNotNull { pathBySelectionKey[it] }.toHashSet() + val pathsToKeep = currentPaths + selectedPaths + + val iterator = selectionKeyByPath.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key !in pathsToKeep) { + pathBySelectionKey.remove(entry.value) + iterator.remove() + } + } + + currentPaths.forEach(::getOrCreateSelectionKey) + } + private fun setupSection(view: View, section: ThumbnailSection, position: Int) { ThumbnailSectionBinding.bind(view).apply { thumbnailSection.text = section.title diff --git a/app/src/main/kotlin/com/goodwy/gallery/asynctasks/GetMediaAsynctask.kt b/app/src/main/kotlin/com/goodwy/gallery/asynctasks/GetMediaAsynctask.kt index 72925f9..6496f43 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/asynctasks/GetMediaAsynctask.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/asynctasks/GetMediaAsynctask.kt @@ -2,21 +2,29 @@ package com.goodwy.gallery.asynctasks import android.content.Context import android.os.AsyncTask +import android.util.Log import com.goodwy.commons.helpers.FAVORITES import com.goodwy.commons.helpers.SORT_BY_DATE_MODIFIED import com.goodwy.commons.helpers.SORT_BY_DATE_TAKEN import com.goodwy.commons.helpers.SORT_BY_SIZE +import android.os.Environment +import com.goodwy.commons.helpers.isRPlus import com.goodwy.gallery.extensions.config import com.goodwy.gallery.extensions.getFavoritePaths import com.goodwy.gallery.helpers.* import com.goodwy.gallery.models.Medium import com.goodwy.gallery.models.ThumbnailItem +import kotlinx.coroutines.* class GetMediaAsynctask( val context: Context, val mPath: String, val isPickImage: Boolean = false, val isPickVideo: Boolean = false, val showAll: Boolean, val callback: (media: ArrayList) -> Unit ) : AsyncTask>() { + companion object { + private const val TAG = "GetMediaAsynctask" + } + private val mediaFetcher = MediaFetcher(context) override fun doInBackground(vararg params: Void): ArrayList { @@ -41,17 +49,44 @@ class GetMediaAsynctask( val media = if (showAll) { val foldersToScan = mediaFetcher.getFoldersToScan().filter { it != RECYCLE_BIN && it != FAVORITES && !context.config.isFolderProtected(it) } - val media = ArrayList() - foldersToScan.forEach { - val newMedia = mediaFetcher.getFilesFrom( - it, isPickImage, isPickVideo, getProperDateTaken, getProperLastModified, getProperFileSize, - favoritePaths, getVideoDurations, lastModifieds, dateTakens.clone() as HashMap, null - ) - media.addAll(newMedia) + + val shouldPrefetchAndroid11Files = isRPlus() && !Environment.isExternalStorageManager() + val prefetchedAndroid11Files = if (shouldPrefetchAndroid11Files) { + val queryStartedAt = System.currentTimeMillis() + Log.d(TAG, "showAll refresh: getAndroid11FolderMedia started at $queryStartedAt") + mediaFetcher.getAndroid11FolderMedia( + isPickImage, + isPickVideo, + favoritePaths, + false, + getProperDateTaken, + HashMap(dateTakens) + ).also { + val queryFinishedAt = System.currentTimeMillis() + Log.d(TAG, "showAll refresh: getAndroid11FolderMedia done in ${queryFinishedAt - queryStartedAt}ms") + } + } else { + null + } + + val allMedia = ArrayList() + val lock = Any() + runBlocking(Dispatchers.IO) { + foldersToScan.map { folderPath -> + async { + if (mediaFetcher.shouldStop) return@async + val newMedia = mediaFetcher.getFilesFrom( + folderPath, isPickImage, isPickVideo, getProperDateTaken, getProperLastModified, + getProperFileSize, favoritePaths, getVideoDurations, + HashMap(lastModifieds), HashMap(dateTakens), prefetchedAndroid11Files + ) + synchronized(lock) { allMedia.addAll(newMedia) } + } + }.awaitAll() } - mediaFetcher.sortMedia(media, context.config.getFolderSorting(SHOW_ALL)) - media + mediaFetcher.sortMedia(allMedia, context.config.getFolderSorting(SHOW_ALL)) + allMedia } else { mediaFetcher.getFilesFrom( mPath, isPickImage, isPickVideo, getProperDateTaken, getProperLastModified, getProperFileSize, favoritePaths, diff --git a/app/src/main/kotlin/com/goodwy/gallery/dialogs/ExcludeFolderDialog.kt b/app/src/main/kotlin/com/goodwy/gallery/dialogs/ExcludeFolderDialog.kt index 1f22aaa..b3877ee 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/dialogs/ExcludeFolderDialog.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/dialogs/ExcludeFolderDialog.kt @@ -11,7 +11,7 @@ import com.goodwy.commons.extensions.setupDialogStuff import com.goodwy.gallery.databinding.DialogExcludeFolderBinding import com.goodwy.gallery.extensions.config -class ExcludeFolderDialog(val activity: BaseSimpleActivity, val selectedPaths: List, val callback: () -> Unit) { +class ExcludeFolderDialog(val activity: BaseSimpleActivity, val selectedPaths: List, val callback: (String) -> Unit) { private val alternativePaths = getAlternativePathsList() private var radioGroup: RadioGroup? = null @@ -43,7 +43,7 @@ class ExcludeFolderDialog(val activity: BaseSimpleActivity, val selectedPaths: L private fun dialogConfirmed() { val path = if (alternativePaths.isEmpty()) selectedPaths[0] else alternativePaths[radioGroup!!.checkedRadioButtonId] activity.config.addExcludedFolder(path) - callback() + callback(path) } private fun getAlternativePathsList(): List { diff --git a/app/src/main/kotlin/com/goodwy/gallery/extensions/Context.kt b/app/src/main/kotlin/com/goodwy/gallery/extensions/Context.kt index cf6ece1..5301e27 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/extensions/Context.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/extensions/Context.kt @@ -12,15 +12,18 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.PictureDrawable import android.media.AudioManager import android.net.Uri +import android.os.Handler +import android.os.Looper import android.os.Process +import android.os.SystemClock import android.provider.MediaStore.Files import android.provider.MediaStore.Images +import android.util.Log import android.widget.ImageView import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import com.bumptech.glide.Glide import com.bumptech.glide.Priority -import com.bumptech.glide.integration.webp.WebpBitmapFactory import com.bumptech.glide.integration.webp.decoder.WebpDownsampler import com.bumptech.glide.integration.webp.decoder.WebpDrawable import com.bumptech.glide.integration.webp.decoder.WebpDrawableTransformation @@ -57,6 +60,81 @@ import kotlin.math.min val Context.audioManager get() = getSystemService(Context.AUDIO_SERVICE) as AudioManager +private const val MEDIA_DB_MAINTENANCE_LOG_TAG = "MediaDbMaintenance" +private const val MEDIA_DB_MAINTENANCE_INTERVAL_MS = 6 * 60 * 60 * 1000L +private const val MEDIA_DB_MAINTENANCE_MAX_DURATION_MS = 250L +private const val MEDIA_DB_MAINTENANCE_BATCH_SIZE = 100 +private const val INCLUDED_FOLDER_THUMBNAIL_SIZE = 640 +private const val INCLUDED_MEDIA_THUMBNAIL_SIZE = 384 +private const val INCLUDED_MEDIA_CACHE_WARMUP_LIMIT = 120 + +fun Context.maybeRunMediaDbMaintenance(force: Boolean = false) { + val now = System.currentTimeMillis() + val elapsedSinceLastRun = now - config.lastMediaDbMaintenance + if (!force && elapsedSinceLastRun in 0 until MEDIA_DB_MAINTENANCE_INTERVAL_MS) { + return + } + + config.lastMediaDbMaintenance = now + Thread { runMediaDbMaintenance() }.start() +} + +private fun Context.runMediaDbMaintenance(maxDurationMs: Long = MEDIA_DB_MAINTENANCE_MAX_DURATION_MS) { + try { + val startTs = SystemClock.elapsedRealtime() + val beforeTotal = mediaDB.getTotalRowCount() + val beforeByFolder = mediaDB.getRowCountByFolder() + + var deletedRows = 0 + var deletedFavoriteRows = 0 + var lastId = 0L + val otgPath = config.OTGPath + + while (SystemClock.elapsedRealtime() - startTs < maxDurationMs) { + val candidates = mediaDB.getCleanupCandidates(lastId, MEDIA_DB_MAINTENANCE_BATCH_SIZE) + if (candidates.isEmpty()) { + break + } + + lastId = candidates.last().id + val staleCandidates = candidates.filter { candidate -> + val dbPath = candidate.path + val pathToCheck = if (candidate.deletedTS != 0L && dbPath.startsWith(RECYCLE_BIN)) { + File(recycleBinPath, dbPath.removePrefix(RECYCLE_BIN)).toString() + } else { + dbPath + } + + !getDoesFilePathExist(pathToCheck, otgPath) + } + + if (staleCandidates.isEmpty()) { + continue + } + + val staleIds = staleCandidates.map { it.id } + deletedRows += mediaDB.deleteByIds(staleIds) + + staleCandidates.filter { it.isFavorite && it.deletedTS == 0L }.forEach { staleCandidate -> + favoritesDB.deleteFavoritePath(staleCandidate.path) + deletedFavoriteRows++ + } + } + + val afterTotal = mediaDB.getTotalRowCount() + val afterByFolder = mediaDB.getRowCountByFolder() + val beforeFolderSummary = beforeByFolder.sortedByDescending { it.rowCount }.take(5).joinToString { "${it.parentPath}:${it.rowCount}" } + val afterFolderSummary = afterByFolder.sortedByDescending { it.rowCount }.take(5).joinToString { "${it.parentPath}:${it.rowCount}" } + + Log.i( + MEDIA_DB_MAINTENANCE_LOG_TAG, + "DB maintenance before=$beforeTotal after=$afterTotal deleted=$deletedRows deletedFavorites=$deletedFavoriteRows beforeFolders=${beforeByFolder.size}[$beforeFolderSummary] afterFolders=${afterByFolder.size}[$afterFolderSummary]" + ) + } catch (e: Exception) { + Log.w(MEDIA_DB_MAINTENANCE_LOG_TAG, "Failed to run media DB maintenance", e) + } +} + fun Context.getHumanizedFilename(path: String): String { val humanized = humanizePath(path) return humanized.substring(humanized.lastIndexOf("/") + 1) @@ -494,6 +572,130 @@ fun Context.rescanFolderMediaSync(path: String) { } } +fun Context.evictFoldersFromCache(paths: Collection) { + ensureBackgroundThread { + evictFoldersFromCacheSync(paths) + } +} + +fun Context.evictFoldersFromCacheSync(paths: Collection) { + val normalizedPaths = paths.map { it.trimEnd('/') } + .filter { it.isNotEmpty() } + .distinct() + + if (normalizedPaths.isEmpty()) { + return + } + + normalizedPaths.forEach { path -> + val childPathPattern = "$path/%" + try { + directoryDB.deleteDirPathWithChildren(path, childPathPattern) + mediaDB.deleteMediaByParentPathWithChildren(path, childPathPattern) + favoritesDB.deleteFavoritesByParentPathWithChildren(path, childPathPattern) + dateTakensDB.deleteDateTakensByParentPathWithChildren(path, childPathPattern) + } catch (ignored: Exception) { + } + } + + val everShownFolders = HashSet(config.everShownFolders) + if (everShownFolders.removeAll { shownPath -> + normalizedPaths.any { path -> + shownPath.equals(path, true) || shownPath.startsWith("$path/", true) + } + }) { + config.everShownFolders = everShownFolders + } + + clearThumbnailCaches() +} + +fun Context.warmIncludedFolderCaches(path: String) { + ensureBackgroundThread { + warmIncludedFolderCachesSync(path) + } +} + +fun Context.warmIncludedFolderCachesSync(path: String) { + val rootPath = path.trimEnd('/') + if (rootPath.isEmpty() || !getDoesFilePathExist(rootPath, config.OTGPath)) { + return + } + + val mediaFetcher = MediaFetcher(this) + val hiddenString = getString(R.string.hidden) + val includedFolders = config.includedFolders + val albumCovers = config.parseAlbumCovers() + val favoritePaths = getFavoritePaths() + val noMediaFolders = getNoMediaFoldersSync() + val getProperFileSize = config.directorySorting and SORT_BY_SIZE != 0 + val lastModifieds = mediaFetcher.getLastModifieds() + val dateTakens = mediaFetcher.getDateTakens() + val foldersToWarm = mediaFetcher.getFoldersToScan().filter { + it.equals(rootPath, true) || it.startsWith("$rootPath/", true) + }.distinct() + + var warmedMediaCount = 0 + for (folder in foldersToWarm) { + val media = mediaFetcher.getFilesFrom( + curPath = folder, + isPickImage = false, + isPickVideo = false, + getProperDateTaken = false, + getProperLastModified = false, + getProperFileSize = getProperFileSize, + favoritePaths = favoritePaths, + getVideoDurations = false, + lastModifieds = HashMap(lastModifieds), + dateTakens = HashMap(dateTakens), + android11Files = null + ) + + if (media.isEmpty()) { + continue + } + + val directory = createDirectoryFromMedia( + path = folder, + curMedia = media, + albumCovers = albumCovers, + hiddenString = hiddenString, + includedFolders = includedFolders, + getProperFileSize = getProperFileSize, + noMediaFolders = noMediaFolders + ) + + try { + directoryDB.insert(directory) + mediaDB.insertAll(media) + } catch (ignored: Exception) { + } + + warmThumbnailIntoCache( + path = directory.tmb, + signature = directory.getKey(), + cropThumbnails = config.cropThumbnails, + roundCorners = if (config.folderStyle == FOLDER_STYLE_SQUARE) ROUNDED_CORNERS_NONE else ROUNDED_CORNERS_SMALL, + thumbnailSize = INCLUDED_FOLDER_THUMBNAIL_SIZE + ) + + for (medium in media) { + if (warmedMediaCount >= INCLUDED_MEDIA_CACHE_WARMUP_LIMIT) { + return + } + + warmThumbnailIntoCache( + path = medium.path, + signature = medium.getKey(), + cropThumbnails = config.cropThumbnails, + roundCorners = if (config.fileRoundedCorners) ROUNDED_CORNERS_BIG else ROUNDED_CORNERS_NONE, + thumbnailSize = INCLUDED_MEDIA_THUMBNAIL_SIZE + ) + warmedMediaCount++ + } + } +} + fun Context.storeDirectoryItems(items: ArrayList) { ensureBackgroundThread { directoryDB.insertAll(items) @@ -542,7 +744,10 @@ fun Context.loadImage( cropThumbnails: Boolean, roundCorners: Int, signature: ObjectKey, + highPriority: Boolean = false, skipMemoryCacheAtPaths: ArrayList? = null, + loadHighPriority: Boolean = false, + thumbnailSize: Int = 0, onError: (() -> Unit)? = null ) { target.isHorizontalScrolling = horizontalScroll @@ -561,9 +766,12 @@ fun Context.loadImage( cropThumbnails = cropThumbnails, roundCorners = roundCorners, signature = signature, + highPriority = highPriority, skipMemoryCacheAtPaths = skipMemoryCacheAtPaths, animate = animateGifs, tryLoadingWithPicasso = type == TYPE_IMAGES && path.isPng(), + loadHighPriority = loadHighPriority, + thumbnailSize = thumbnailSize, onError = onError ) } @@ -609,19 +817,32 @@ fun Context.loadImageBase( cropThumbnails: Boolean, roundCorners: Int, signature: ObjectKey, + highPriority: Boolean = false, skipMemoryCacheAtPaths: ArrayList? = null, animate: Boolean = false, tryLoadingWithPicasso: Boolean = false, crossFadeDuration: Int = THUMBNAIL_FADE_DURATION_MS, + loadHighPriority: Boolean = false, + thumbnailSize: Int = 0, onError: (() -> Unit)? = null ) { + val priority = when { + loadHighPriority -> Priority.IMMEDIATE + highPriority -> Priority.HIGH + else -> Priority.NORMAL + } + val options = RequestOptions() .signature(signature) .skipMemoryCache(skipMemoryCacheAtPaths?.contains(path) == true) - .priority(Priority.LOW) + .priority(priority) .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .format(DecodeFormat.PREFER_ARGB_8888) + if (thumbnailSize > 0) { + options.override(thumbnailSize) + } + if (cropThumbnails) { options.optionalTransform(CenterCrop()) options.optionalTransform( @@ -658,7 +879,6 @@ fun Context.loadImageBase( ) } - WebpBitmapFactory.sUseSystemDecoder = false // CVE-2023-4863 var builder = Glide.with(applicationContext) .load(path) .apply(options) @@ -693,6 +913,104 @@ fun Context.loadImageBase( builder.into(target) } +fun Context.preloadImageBase( + path: String, + cropThumbnails: Boolean, + roundCorners: Int, + signature: ObjectKey, + highPriority: Boolean = false, + skipMemoryCache: Boolean = false, + thumbnailSize: Int = 0, +): Target { + val options = RequestOptions() + .signature(signature) + .skipMemoryCache(skipMemoryCache) + .priority(if (highPriority) Priority.HIGH else Priority.NORMAL) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .format(DecodeFormat.PREFER_ARGB_8888) + .dontAnimate() + .decode(Bitmap::class.java) + + if (thumbnailSize > 0) { + options.override(thumbnailSize) + } + + if (cropThumbnails) { + options.optionalTransform(CenterCrop()) + options.optionalTransform( + WebpDrawable::class.java, + WebpDrawableTransformation(CenterCrop()) + ) + } else { + options.optionalTransform(FitCenter()) + options.optionalTransform(WebpDrawable::class.java, WebpDrawableTransformation(FitCenter())) + } + + if (roundCorners != ROUNDED_CORNERS_NONE) { + val cornerSize = + if (roundCorners == ROUNDED_CORNERS_SMALL) com.goodwy.commons.R.dimen.rounded_corner_radius_big else com.goodwy.commons.R.dimen.dialog_corner_radius + val cornerRadius = resources.getDimension(cornerSize).toInt() + val roundedCornersTransform = RoundedCorners(cornerRadius) + options.optionalTransform(MultiTransformation(CenterCrop(), roundedCornersTransform)) + options.optionalTransform( + WebpDrawable::class.java, + MultiTransformation( + WebpDrawableTransformation(CenterCrop()), + WebpDrawableTransformation(roundedCornersTransform) + ) + ) + } + + return Glide.with(applicationContext) + .load(path) + .apply(options) + .set(WebpDownsampler.USE_SYSTEM_DECODER, false) // CVE-2023-4863 + .preload() +} + +private fun Context.warmThumbnailIntoCache( + path: String, + signature: ObjectKey, + cropThumbnails: Boolean, + roundCorners: Int, + thumbnailSize: Int, +) { + if (path.isEmpty() || path.isSvg()) { + return + } + + var pathToLoad = path + if (config.OTGPath.isNotEmpty() && isPathOnOTG(pathToLoad)) { + pathToLoad = pathToLoad.getOTGPublicPath(applicationContext) + } + + try { + preloadImageBase( + path = pathToLoad, + cropThumbnails = cropThumbnails, + roundCorners = roundCorners, + signature = signature, + highPriority = true, + thumbnailSize = thumbnailSize + ) + } catch (ignored: Exception) { + } +} + +private fun Context.clearThumbnailCaches() { + try { + val glide = Glide.get(applicationContext) + glide.clearDiskCache() + Handler(Looper.getMainLooper()).post { + try { + glide.clearMemory() + } catch (ignored: Exception) { + } + } + } catch (ignored: Exception) { + } +} + fun Context.loadSVG( path: String, target: MySquareImageView, diff --git a/app/src/main/kotlin/com/goodwy/gallery/helpers/Config.kt b/app/src/main/kotlin/com/goodwy/gallery/helpers/Config.kt index 58a733a..6154089 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/helpers/Config.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/helpers/Config.kt @@ -477,6 +477,10 @@ class Config(context: Context) : BaseConfig(context) { get() = prefs.getLong(LAST_BIN_CHECK, 0L) set(lastBinCheck) = prefs.edit().putLong(LAST_BIN_CHECK, lastBinCheck).apply() + var lastMediaDbMaintenance: Long + get() = prefs.getLong(LAST_MEDIA_DB_MAINTENANCE, 0L) + set(lastMaintenance) = prefs.edit().putLong(LAST_MEDIA_DB_MAINTENANCE, lastMaintenance).apply() + var showHighestQuality: Boolean get() = prefs.getBoolean(SHOW_HIGHEST_QUALITY, false) set(showHighestQuality) = prefs.edit().putBoolean(SHOW_HIGHEST_QUALITY, showHighestQuality).apply() @@ -609,4 +613,8 @@ class Config(context: Context) : BaseConfig(context) { var fontSizeDir: Int get() = prefs.getInt(FONT_SIZE_DIR, FONT_SIZE_MEDIUM) set(size) = prefs.edit { putInt(FONT_SIZE_DIR, size) } + + var diskCacheSizeMB: Int + get() = prefs.getInt(DISK_CACHE_SIZE_MB, DEFAULT_DISK_CACHE_SIZE_MB).coerceIn(MIN_DISK_CACHE_SIZE_MB, MAX_DISK_CACHE_SIZE_MB) + set(value) = prefs.edit().putInt(DISK_CACHE_SIZE_MB, value.coerceIn(MIN_DISK_CACHE_SIZE_MB, MAX_DISK_CACHE_SIZE_MB)).apply() } diff --git a/app/src/main/kotlin/com/goodwy/gallery/helpers/Constants.kt b/app/src/main/kotlin/com/goodwy/gallery/helpers/Constants.kt index ee99744..472122a 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/helpers/Constants.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/helpers/Constants.kt @@ -73,6 +73,7 @@ const val SHOW_RECYCLE_BIN_LAST = "show_recycle_bin_last" const val ALLOW_ZOOMING_IMAGES = "allow_zooming_images" const val WAS_SVG_SHOWING_HANDLED = "was_svg_showing_handled" const val LAST_BIN_CHECK = "last_bin_check" +const val LAST_MEDIA_DB_MAINTENANCE = "last_media_db_maintenance" const val SHOW_HIGHEST_QUALITY = "show_highest_quality" const val ALLOW_DOWN_GESTURE = "allow_down_gesture" const val LAST_EDITOR_CROP_ASPECT_RATIO = "last_editor_crop_aspect_ratio" @@ -110,6 +111,12 @@ const val HIDE_GROUPING_BUTTON = "hide_grouping_button" const val HIDE_GROUPING_BAR_WHEN_SCROLLING = "hide_grouping_bar_when_scrolling" const val FONT_SIZE_DIR = "font_size_dir" +// disk cache +const val DEFAULT_DISK_CACHE_SIZE_MB = 512 +const val MIN_DISK_CACHE_SIZE_MB = 100 +const val MAX_DISK_CACHE_SIZE_MB = 2048 +const val DISK_CACHE_SIZE_MB = "disk_cache_size_mb" + // slideshow const val SLIDESHOW_INTERVAL = "slideshow_interval" const val SLIDESHOW_INCLUDE_VIDEOS = "slideshow_include_videos" diff --git a/app/src/main/kotlin/com/goodwy/gallery/helpers/MediaFetcher.kt b/app/src/main/kotlin/com/goodwy/gallery/helpers/MediaFetcher.kt index cc50610..73db5e5 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/helpers/MediaFetcher.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/helpers/MediaFetcher.kt @@ -57,12 +57,12 @@ class MediaFetcher(val context: Context) { if (curMedia.isEmpty()) { val newMedia = getMediaInFolder( curPath, isPickImage, isPickVideo, filterMedia, getProperDateTaken, getProperLastModified, getProperFileSize, - favoritePaths, getVideoDurations, lastModifieds.clone() as HashMap, dateTakens.clone() as HashMap + favoritePaths, getVideoDurations, HashMap(lastModifieds), HashMap(dateTakens) ) if (curPath == FAVORITES && isRPlus() && !isExternalStorageManager()) { val files = - getAndroid11FolderMedia(isPickImage, isPickVideo, favoritePaths, true, getProperDateTaken, dateTakens.clone() as HashMap) + getAndroid11FolderMedia(isPickImage, isPickVideo, favoritePaths, true, getProperDateTaken, HashMap(dateTakens)) newMedia.forEach { newMedium -> for ((folder, media) in files) { media.forEach { medium -> @@ -417,7 +417,7 @@ class MediaFetcher(val context: Context) { } val isFavorite = favoritePaths.contains(path) - val medium = Medium(null, filename, path, file.parent, lastModified, dateTaken, size, type, videoDuration, isFavorite, 0L, 0L) + val medium = Medium(null, filename, path, file.parent ?: "", lastModified, dateTaken, size, type, videoDuration, isFavorite, 0L, 0L) media.add(medium) } } @@ -804,7 +804,7 @@ class MediaFetcher(val context: Context) { val currentGrouping = context.config.getFolderGrouping(pathToCheck) if (currentGrouping and GROUP_BY_NONE != 0 || currentGrouping and GROUP_BY_LAST_MODIFIED_NONE != 0 || currentGrouping and GROUP_BY_DATE_TAKEN_NONE != 0 || currentGrouping and GROUP_BY_OTHER_NONE != 0) { - return media as ArrayList + return ArrayList(media) } val thumbnailItems = ArrayList() diff --git a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DateTakensDao.kt b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DateTakensDao.kt index f43f1c9..5d15103 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DateTakensDao.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DateTakensDao.kt @@ -16,4 +16,7 @@ interface DateTakensDao { @Query("SELECT full_path, filename, parent_path, date_taken, last_fixed, last_modified FROM date_takens") fun getAllDateTakens(): List + + @Query("DELETE FROM date_takens WHERE parent_path = :path COLLATE NOCASE OR parent_path LIKE :childPathPattern COLLATE NOCASE") + fun deleteDateTakensByParentPathWithChildren(path: String, childPathPattern: String) } diff --git a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryDao.kt b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryDao.kt index f9acc00..0993800 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryDao.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryDao.kt @@ -21,6 +21,9 @@ interface DirectoryDao { @Query("DELETE FROM directories WHERE path = :path COLLATE NOCASE") fun deleteDirPath(path: String) + @Query("DELETE FROM directories WHERE path = :path COLLATE NOCASE OR path LIKE :childPathPattern COLLATE NOCASE") + fun deleteDirPathWithChildren(path: String, childPathPattern: String) + @Query("UPDATE OR REPLACE directories SET thumbnail = :thumbnail, media_count = :mediaCnt, last_modified = :lastModified, date_taken = :dateTaken, size = :size, media_types = :mediaTypes, sort_value = :sortValue WHERE path = :path COLLATE NOCASE") fun updateDirectory(path: String, thumbnail: String, mediaCnt: Int, lastModified: Long, dateTaken: Long, size: Long, mediaTypes: Int, sortValue: String) diff --git a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryOperationsListener.kt b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryOperationsListener.kt index 1818da9..e97be4b 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryOperationsListener.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/interfaces/DirectoryOperationsListener.kt @@ -11,4 +11,6 @@ interface DirectoryOperationsListener { fun recheckPinnedFolders() fun updateDirectories(directories: ArrayList) + + fun excludeDirectories(paths: Set) } diff --git a/app/src/main/kotlin/com/goodwy/gallery/interfaces/FavoritesDao.kt b/app/src/main/kotlin/com/goodwy/gallery/interfaces/FavoritesDao.kt index 4727496..aa22eac 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/interfaces/FavoritesDao.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/interfaces/FavoritesDao.kt @@ -26,6 +26,9 @@ interface FavoritesDao { @Query("DELETE FROM favorites WHERE full_path = :path COLLATE NOCASE") fun deleteFavoritePath(path: String) + @Query("DELETE FROM favorites WHERE parent_path = :path COLLATE NOCASE OR parent_path LIKE :childPathPattern COLLATE NOCASE") + fun deleteFavoritesByParentPathWithChildren(path: String, childPathPattern: String) + @Query("DELETE FROM favorites") fun clearFavorites() } diff --git a/app/src/main/kotlin/com/goodwy/gallery/interfaces/MediumDao.kt b/app/src/main/kotlin/com/goodwy/gallery/interfaces/MediumDao.kt index 97d1e70..27ff94f 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/interfaces/MediumDao.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/interfaces/MediumDao.kt @@ -3,6 +3,18 @@ package com.goodwy.gallery.interfaces import androidx.room.* import com.goodwy.gallery.models.Medium +data class FolderRowCount( + @ColumnInfo(name = "parent_path") val parentPath: String, + @ColumnInfo(name = "row_count") val rowCount: Long +) + +data class MediumCleanupCandidate( + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "full_path") val path: String, + @ColumnInfo(name = "is_favorite") val isFavorite: Boolean, + @ColumnInfo(name = "deleted_ts") val deletedTS: Long +) + @Dao interface MediumDao { @Query("SELECT filename, full_path, parent_path, last_modified, date_taken, size, type, video_duration, is_favorite, deleted_ts, media_store_id FROM media WHERE deleted_ts = 0 AND parent_path = :path COLLATE NOCASE") @@ -20,6 +32,15 @@ interface MediumDao { @Query("SELECT COUNT(filename) FROM media WHERE deleted_ts != 0") fun getDeletedMediaCount(): Long + @Query("SELECT COUNT(*) FROM media") + fun getTotalRowCount(): Long + + @Query("SELECT parent_path, COUNT(*) AS row_count FROM media GROUP BY parent_path") + fun getRowCountByFolder(): List + + @Query("SELECT id, full_path, is_favorite, deleted_ts FROM media WHERE id > :afterId ORDER BY id LIMIT :limit") + fun getCleanupCandidates(afterId: Long, limit: Int): List + @Query("SELECT filename, full_path, parent_path, last_modified, date_taken, size, type, video_duration, is_favorite, deleted_ts, media_store_id FROM media WHERE deleted_ts < :timestmap AND deleted_ts != 0") fun getOldRecycleBinItems(timestmap: Long): List @@ -35,6 +56,12 @@ interface MediumDao { @Query("DELETE FROM media WHERE full_path = :path COLLATE NOCASE") fun deleteMediumPath(path: String) + @Query("DELETE FROM media WHERE parent_path = :path COLLATE NOCASE OR parent_path LIKE :childPathPattern COLLATE NOCASE") + fun deleteMediaByParentPathWithChildren(path: String, childPathPattern: String) + + @Query("DELETE FROM media WHERE id IN (:ids)") + fun deleteByIds(ids: List): Int + @Query("UPDATE OR REPLACE media SET filename = :newFilename, full_path = :newFullPath, parent_path = :newParentPath WHERE full_path = :oldPath COLLATE NOCASE") fun updateMedium(oldPath: String, newParentPath: String, newFilename: String, newFullPath: String) diff --git a/app/src/main/kotlin/com/goodwy/gallery/svg/SvgModule.kt b/app/src/main/kotlin/com/goodwy/gallery/svg/SvgModule.kt index bcbf8a2..6a8c4cf 100644 --- a/app/src/main/kotlin/com/goodwy/gallery/svg/SvgModule.kt +++ b/app/src/main/kotlin/com/goodwy/gallery/svg/SvgModule.kt @@ -6,8 +6,16 @@ import android.graphics.drawable.PictureDrawable import com.bumptech.glide.Glide import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.DecodeFormat +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory +import com.bumptech.glide.load.engine.cache.LruResourceCache +import com.bumptech.glide.load.engine.cache.MemorySizeCalculator import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.request.RequestOptions import com.caverock.androidsvg.SVG +import com.bumptech.glide.integration.webp.WebpBitmapFactory +import com.goodwy.gallery.extensions.config import java.io.InputStream @@ -17,5 +25,26 @@ class SvgModule : AppGlideModule() { registry.register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder()).append(InputStream::class.java, SVG::class.java, SvgDecoder()) } + override fun applyOptions(context: Context, builder: com.bumptech.glide.GlideBuilder) { + // CVE-2023-4863: disable vulnerable system WebP decoder once at init + WebpBitmapFactory.sUseSystemDecoder = false + + // Configure disk cache: size from user config in internal cache directory + builder.setDiskCache(InternalCacheDiskCacheFactory(context, "image_cache", context.config.diskCacheSizeMB.toLong() * 1024L * 1024L)) + + // Configure memory cache: 3 screens worth of memory + val memorySizeCalculator = MemorySizeCalculator.Builder(context) + .setMemoryCacheScreens(3f) + .build() + builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong())) + + // Set default request options + builder.setDefaultRequestOptions( + RequestOptions() + .format(DecodeFormat.PREFER_ARGB_8888) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + ) + } + override fun isManifestParsingEnabled() = false } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index cb5447b..15f0fd9 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -512,6 +512,27 @@ app:switchPadding="@dimen/bigger_margin" /> + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0541e9..cc98bac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,8 @@ Remember last video playback position Loop videos Animate GIFs at thumbnails + Thumbnail cache size + Restart the app to apply the new cache size Max brightness when viewing fullscreen media Crop thumbnails into squares Show video durations