From 6ab9aecc93e710d661064792df4f827f83b7bba2 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 17 Apr 2022 23:22:02 +0800 Subject: [PATCH 1/3] Remote file size limit for thumbnails Fixes #1846 --- .../LayoutElementParcelableEspressoTest.kt | 79 +++++ ...rModule.java => AmazeFileManagerModule.kt} | 41 ++- .../filemanager/adapters/RecyclerAdapter.java | 185 ++++++---- .../data/LayoutElementParcelable.java | 24 +- .../glide/RecyclerPreloadModelProvider.java | 16 +- .../glide/apkimage/ApkImageDataFetcher.java | 83 ----- .../glide/apkimage/ApkImageDataFetcher.kt | 67 ++++ ...odelLoader.java => ApkImageModelLoader.kt} | 50 ++- ...ory.java => ApkImageModelLoaderFactory.kt} | 31 +- ...lFactory.java => CloudIconModelFactory.kt} | 32 +- .../glide/cloudicon/CloudIconModelLoader.java | 65 ---- .../glide/cloudicon/CloudIconModelLoader.kt | 57 +++ .../ui/fragments/MainFragment.java | 38 ++ .../PreferencesConstants.kt | 4 + .../preferencefragments/UiPrefsFragment.kt | 78 ++++- .../amaze/filemanager/utils/AppConstants.kt | 2 + app/src/main/res/values/arrays.xml | 8 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/ui_prefs.xml | 5 + .../RecyclerAdapterShouldLoadThumbnailTest.kt | 296 ++++++++++++++++ .../data/LayoutElementParcelableTest.kt | 324 ++++++++++++++++++ .../AbstractPreferencesFragmentTest.kt | 131 +++++++ .../MainFragmentThumbnailPrefChangeTest.kt | 180 ++++++++++ .../ui/fragments/UiPrefsFragmentTest.kt | 117 +++++++ .../cloud/CloudStreamSourceTest.java | 4 +- 25 files changed, 1608 insertions(+), 311 deletions(-) create mode 100644 app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt rename app/src/main/java/com/amaze/filemanager/{AmazeFileManagerModule.java => AmazeFileManagerModule.kt} (58%) delete mode 100644 app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java create mode 100644 app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.kt rename app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/{ApkImageModelLoader.java => ApkImageModelLoader.kt} (55%) rename app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/{ApkImageModelLoaderFactory.java => ApkImageModelLoaderFactory.kt} (59%) rename app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/{CloudIconModelFactory.java => CloudIconModelFactory.kt} (56%) delete mode 100644 app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java create mode 100644 app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt create mode 100644 app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterShouldLoadThumbnailTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/fragments/AbstractPreferencesFragmentTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentThumbnailPrefChangeTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt diff --git a/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt new file mode 100644 index 0000000000..bd8b7d6d18 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.adapters.data.IconDataParcelable.IMAGE_FROMCLOUD +import com.amaze.filemanager.adapters.data.IconDataParcelable.IMAGE_RES +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LayoutElementParcelableEspressoTest { + @Test + fun testConstructorWithBigRemoteFile() { + val a = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "17.89 MB", + 17889945, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + a.iconData.run { + assertEquals(IMAGE_RES, type) + } + } + + @Test + fun testConstructorWithSmallRemoteFile() { + val b = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "100 KB", + 102400, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + b.iconData.run { + assertEquals(IMAGE_FROMCLOUD, type) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java b/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.kt similarity index 58% rename from app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java rename to app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.kt index 1250db15f1..ab9cd9a980 100644 --- a/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java +++ b/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.kt @@ -18,25 +18,30 @@ * along with this program. If not, see . */ -package com.amaze.filemanager; +package com.amaze.filemanager -import com.amaze.filemanager.adapters.glide.apkimage.ApkImageModelLoaderFactory; -import com.amaze.filemanager.adapters.glide.cloudicon.CloudIconModelFactory; -import com.bumptech.glide.Glide; -import com.bumptech.glide.Registry; -import com.bumptech.glide.annotation.GlideModule; -import com.bumptech.glide.module.AppGlideModule; +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import com.amaze.filemanager.adapters.glide.apkimage.ApkImageModelLoaderFactory +import com.amaze.filemanager.adapters.glide.cloudicon.CloudIconModelFactory +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; - -/** Ensures that Glide's generated API is created for the Gallery sample. */ @GlideModule -public class AmazeFileManagerModule extends AppGlideModule { - @Override - public void registerComponents(Context context, Glide glide, Registry registry) { - registry.prepend(String.class, Drawable.class, new ApkImageModelLoaderFactory(context)); - registry.prepend(String.class, Bitmap.class, new CloudIconModelFactory(context)); - } +class AmazeFileManagerModule : AppGlideModule() { + override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ) { + registry.prepend( + String::class.java, + Drawable::class.java, + ApkImageModelLoaderFactory(context), + ) + registry.prepend(String::class.java, Bitmap::class.java, CloudIconModelFactory(context)) + } } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java index cf2731262f..777cc8f246 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java @@ -43,8 +43,10 @@ import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HEADERS; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_LAST_MODIFIED; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_PERMISSIONS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_USE_CIRCULAR_IMAGES; +import static com.amaze.filemanager.utils.AppConstants.MEGABYTE; import java.util.ArrayList; import java.util.Collections; @@ -111,6 +113,7 @@ import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.view.ActionMode; import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.AppCompatImageView; @@ -269,7 +272,7 @@ public void toggleChecked(int position, AppCompatImageView imageView) { private void invalidateSelection() { if (mainFragment.getMainFragmentViewModel() != null) { mainFragment - .getMainActivity() + .requireMainActivity() .setListItemSelected( mainFragment.getMainFragmentViewModel().getCheckedItems().size() != 0); } @@ -278,22 +281,22 @@ private void invalidateSelection() { public void invalidateActionMode() { if (mainFragment.getMainFragmentViewModel() != null) { // we have the actionmode visible, invalidate it's views - if (mainFragment.getMainActivity().getListItemSelected()) { - if (mainFragment.getMainActivity().getActionModeHelper().getActionMode() == null) { + if (mainFragment.requireMainActivity().getListItemSelected()) { + if (mainFragment.requireMainActivity().getActionModeHelper().getActionMode() == null) { ActionMode.Callback mActionModeCallback = - mainFragment.getMainActivity().getActionModeHelper(); + mainFragment.requireMainActivity().getActionModeHelper(); mainFragment - .getMainActivity() + .requireMainActivity() .getActionModeHelper() .setActionMode( - mainFragment.getMainActivity().startSupportActionMode(mActionModeCallback)); + mainFragment.requireMainActivity().startSupportActionMode(mActionModeCallback)); } else { - mainFragment.getMainActivity().getActionModeHelper().getActionMode().invalidate(); + mainFragment.requireMainActivity().getActionModeHelper().getActionMode().invalidate(); } } else { - if (mainFragment.getMainActivity().getActionModeHelper().getActionMode() != null) { - mainFragment.getMainActivity().getActionModeHelper().getActionMode().finish(); - mainFragment.getMainActivity().getActionModeHelper().setActionMode(null); + if (mainFragment.requireMainActivity().getActionModeHelper().getActionMode() != null) { + mainFragment.requireMainActivity().getActionModeHelper().getActionMode().finish(); + mainFragment.requireMainActivity().getActionModeHelper().setActionMode(null); } } } @@ -478,11 +481,10 @@ public ArrayList getCheckedItems() { return mainFragment.getMainFragmentViewModel().getCheckedItems(); } - @Nullable - public ArrayList getItemsDigested() { + public List getItemsDigested() { return mainFragment.getMainFragmentViewModel() != null ? mainFragment.getMainFragmentViewModel().getAdapterListItems() - : null; + : Collections.emptyList(); } public boolean isItemsDigestedNullOrEmpty() { @@ -905,7 +907,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() switch (rowItem.filetype) { case Icons.IMAGE: case Icons.VIDEO: - if (getBoolean(PREFERENCE_SHOW_THUMB) && rowItem.getMode() != OpenMode.FTP) { + if (shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { if (getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES)) { showThumbnailWithBackground( holder, rowItem.iconData, holder.pictureIcon, rowItem.iconData::setImageBroken); @@ -921,7 +923,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() } break; case Icons.APK: - if (getBoolean(PREFERENCE_SHOW_THUMB)) { + if (shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { showThumbnailWithBackground( holder, rowItem.iconData, holder.apkIcon, rowItem.iconData::setImageBroken); } else { @@ -964,7 +966,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() if ((rowItem.filetype != Icons.IMAGE && rowItem.filetype != Icons.APK && rowItem.filetype != Icons.VIDEO) - || !getBoolean(PREFERENCE_SHOW_THUMB)) { + || !shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { holder.apkIcon.setVisibility(View.GONE); holder.pictureIcon.setVisibility(View.GONE); holder.genericIcon.setVisibility(View.VISIBLE); @@ -978,7 +980,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() if (!((rowItem.filetype == Icons.APK || rowItem.filetype == Icons.IMAGE || rowItem.filetype == Icons.VIDEO) - && getBoolean(PREFERENCE_SHOW_THUMB))) { + && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode()))) { holder.genericIcon.setVisibility(View.VISIBLE); GradientDrawable gradientDrawable = (GradientDrawable) holder.genericIcon.getBackground(); @@ -1054,23 +1056,35 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() holder.checkImageViewGrid.setVisibility(View.INVISIBLE); if (rowItem.filetype == Icons.IMAGE || rowItem.filetype == Icons.VIDEO) { - if (getBoolean(PREFERENCE_SHOW_THUMB) && rowItem.getMode() != OpenMode.FTP) { + if (getBoolean(PREFERENCE_SHOW_THUMB) + && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { holder.imageView1.setVisibility(View.VISIBLE); holder.imageView1.setImageDrawable(null); if (utilsProvider.getAppTheme().equals(AppTheme.DARK) || utilsProvider.getAppTheme().equals(AppTheme.BLACK)) holder.imageView1.setBackgroundColor(Color.BLACK); showRoundedThumbnail( - holder, rowItem.iconData, holder.imageView1, rowItem.iconData::setImageBroken); + holder, + rowItem.longSize, + rowItem.getMode(), + rowItem.iconData, + holder.imageView1, + rowItem.iconData::setImageBroken); } else { if (rowItem.filetype == Icons.IMAGE) holder.genericIcon.setImageResource(R.drawable.ic_doc_image); else holder.genericIcon.setImageResource(R.drawable.ic_doc_video_am); } } else if (rowItem.filetype == Icons.APK) { - if (getBoolean(PREFERENCE_SHOW_THUMB)) + if (getBoolean(PREFERENCE_SHOW_THUMB) + && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) showRoundedThumbnail( - holder, rowItem.iconData, holder.genericIcon, rowItem.iconData::setImageBroken); + holder, + rowItem.longSize, + rowItem.getMode(), + rowItem.iconData, + holder.genericIcon, + rowItem.iconData::setImageBroken); else { holder.genericIcon.setImageResource(R.drawable.ic_doc_apk_white); } @@ -1086,7 +1100,8 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() } else { switch (rowItem.filetype) { case Icons.VIDEO: - if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(videoColor); + if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) + iconBackground.setBackgroundColor(videoColor); break; case Icons.AUDIO: iconBackground.setBackgroundColor(audioColor); @@ -1107,10 +1122,12 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() iconBackground.setBackgroundColor(genericColor); break; case Icons.APK: - if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(apkColor); + if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) + iconBackground.setBackgroundColor(apkColor); break; case Icons.IMAGE: - if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(videoColor); + if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) + iconBackground.setBackgroundColor(videoColor); break; default: iconBackground.setBackgroundColor(iconSkinColor); @@ -1129,7 +1146,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() if ((rowItem.filetype != Icons.IMAGE && rowItem.filetype != Icons.APK && rowItem.filetype != Icons.VIDEO) - || !getBoolean(PREFERENCE_SHOW_THUMB)) { + || !shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { View iconBackground = getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) ? holder.genericIcon : holder.iconLayout; iconBackground.setBackgroundColor(goBackColor); @@ -1347,6 +1364,8 @@ public boolean onResourceReady( private void showRoundedThumbnail( ItemViewHolder viewHolder, + long longSize, + OpenMode mode, IconDataParcelable iconData, AppCompatImageView view, OnImageProcessed errorListener) { @@ -1374,42 +1393,44 @@ private void showRoundedThumbnail( Glide.with(mainFragment).load(iconData.loadingImage).into(viewHolder.genericIcon); view.setVisibility(View.INVISIBLE); - RequestListener requestListener = - new RequestListener() { - @Override - public boolean onLoadFailed( - @Nullable GlideException e, Object model, Target target, boolean isFirstResource) { - iconBackground.setBackgroundColor(grey_color); - new Handler( - msg -> { - Glide.with(mainFragment) - .load(R.drawable.ic_broken_image_white_24dp) - .into(viewHolder.genericIcon); - return false; - }) - .obtainMessage() - .sendToTarget(); - errorListener.onImageProcessed(true); - return true; - } + if (longSize > 0) { + RequestListener requestListener = + new RequestListener() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + iconBackground.setBackgroundColor(grey_color); + new Handler( + msg -> { + Glide.with(mainFragment) + .load(R.drawable.ic_broken_image_white_24dp) + .into(viewHolder.genericIcon); + return false; + }) + .obtainMessage() + .sendToTarget(); + errorListener.onImageProcessed(true); + return true; + } - @Override - public boolean onResourceReady( - Drawable resource, - Object model, - Target target, - DataSource dataSource, - boolean isFirstResource) { - viewHolder.genericIcon.setImageDrawable(null); - viewHolder.genericIcon.setVisibility(View.GONE); - view.setVisibility(View.VISIBLE); - iconBackground.setBackgroundColor( - mainFragment.getResources().getColor(android.R.color.transparent)); - errorListener.onImageProcessed(false); - return false; - } - }; - modelProvider.getPreloadRequestBuilder(iconData).listener(requestListener).into(view); + @Override + public boolean onResourceReady( + Drawable resource, + Object model, + Target target, + DataSource dataSource, + boolean isFirstResource) { + viewHolder.genericIcon.setImageDrawable(null); + viewHolder.genericIcon.setVisibility(View.GONE); + view.setVisibility(View.VISIBLE); + iconBackground.setBackgroundColor( + mainFragment.getResources().getColor(android.R.color.transparent)); + errorListener.onImageProcessed(false); + return false; + } + }; + modelProvider.getPreloadRequestBuilder(iconData).listener(requestListener).into(view); + } } private void showPopup(@NonNull View view, @NonNull final LayoutElementParcelable rowItem) { @@ -1519,6 +1540,50 @@ private boolean getBoolean(String key) { return preferenceActivity.getBoolean(key); } + private boolean shouldLoadThumbnail(long longSize, OpenMode mode) { + int[] maxSizes = + preferenceActivity.getResources().getIntArray(R.array.thumbnailDisplaySizeLimitPreference); + int idx = preferenceActivity.getPrefs().getInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 0); + return shouldLoadThumbnailStatic( + getBoolean(PREFERENCE_SHOW_THUMB), maxSizes, idx, longSize, mode); + } + + /** + * Core thumbnail-loading decision logic, extracted for unit-test visibility. + * + * @param showThumb value of {@link PreferencesConstants#PREFERENCE_SHOW_THUMB} preference + * @param maxSizesInMb the {@code R.array.thumbnailDisplaySizeLimitPreference} int array (MB per + * index; index 0 means "no limit") + * @param capIndex value of {@link PreferencesConstants#PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE} + * preference (index into {@code maxSizesInMb}) + * @param longSize file size in bytes + * @param mode open mode + * @return {@code true} if a thumbnail should be fetched for this file + */ + @VisibleForTesting + static boolean shouldLoadThumbnailStatic( + boolean showThumb, int[] maxSizesInMb, int capIndex, long longSize, OpenMode mode) { + if (!showThumb) { + return false; + } + switch (mode) { + case SMB: + case SFTP: + case DROPBOX: + case GDRIVE: + case ONEDRIVE: + case BOX: + return capIndex == 0 || longSize <= ((long) maxSizesInMb[capIndex] * MEGABYTE); + // Until we find a way to properly handle threading issues with thread unsafe FTPClient, + // we refrain from loading any files via FTP as file thumbnail. - TranceLove + case FTP: + return false; + default: + // Local modes (FILE, ROOT, OTG, DOCUMENT_FILE, ANDROID_DATA, etc.) — no size cap + return true; + } + } + public static class ListItem { public static final int CHECKED = 0, NOT_CHECKED = 1, UNCHECKABLE = 2; diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java b/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java index 82539cefb3..2648239665 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java @@ -20,9 +20,13 @@ package com.amaze.filemanager.adapters.data; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT; +import static com.amaze.filemanager.utils.AppConstants.MEGABYTE; + import java.io.File; -import java.util.Calendar; +import com.amaze.filemanager.R; import com.amaze.filemanager.fileoperations.filesystem.OpenMode; import com.amaze.filemanager.filesystem.HybridFileParcelable; import com.amaze.filemanager.filesystem.files.sort.ComparableParcelable; @@ -35,12 +39,10 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; public class LayoutElementParcelable implements Parcelable, ComparableParcelable { - private static final String CURRENT_YEAR = - String.valueOf(Calendar.getInstance().get(Calendar.YEAR)); - public final boolean isBack; public final int filetype; public final IconDataParcelable iconData; @@ -149,6 +151,12 @@ public LayoutElementParcelable( @DrawableRes int fallbackIcon = Icons.loadMimeIcon(path, isDirectory); this.mode = openMode; if (useThumbs) { + int[] maxSizes = c.getResources().getIntArray(R.array.thumbnailDisplaySizeLimitPreference); + int idx = + PreferenceManager.getDefaultSharedPreferences(c) + .getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT); switch (mode) { case SMB: case SFTP: @@ -156,8 +164,11 @@ public LayoutElementParcelable( case GDRIVE: case ONEDRIVE: case BOX: - if (!isDirectory - && (filetype == Icons.IMAGE || filetype == Icons.VIDEO || filetype == Icons.APK)) { + boolean shouldCloudIcon = + !isDirectory + && (filetype == Icons.IMAGE || filetype == Icons.VIDEO || filetype == Icons.APK) + && (idx == 0 || longSize <= (long) maxSizes[idx] * MEGABYTE); + if (shouldCloudIcon) { this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_FROMCLOUD, path, fallbackIcon); } else { @@ -176,6 +187,7 @@ public LayoutElementParcelable( } else { this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); } + break; } } else { this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java index a892be042a..827260a010 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java @@ -68,12 +68,16 @@ public List getPreloadItems(int position) { @Nullable public RequestBuilder getPreloadRequestBuilder(IconDataParcelable iconData) { RequestBuilder requestBuilder; - if (iconData.type == IconDataParcelable.IMAGE_FROMFILE) { - requestBuilder = request.load(iconData.path); - } else if (iconData.type == IconDataParcelable.IMAGE_FROMCLOUD) { - requestBuilder = request.load(iconData.path).diskCacheStrategy(DiskCacheStrategy.NONE); - } else { - requestBuilder = request.load(iconData.image); + switch (iconData.type) { + case IconDataParcelable.IMAGE_FROMFILE: + requestBuilder = request.load(iconData.path); + break; + case IconDataParcelable.IMAGE_FROMCLOUD: + requestBuilder = request.load(iconData.path).diskCacheStrategy(DiskCacheStrategy.NONE); + break; + default: + requestBuilder = request.load(iconData.image); + break; } return requestBuilder; } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java deleted file mode 100644 index 1cb4ca20dc..0000000000 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai and Contributors. - * - * This file is part of Amaze File Manager. - * - * Amaze File Manager is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.amaze.filemanager.adapters.glide.apkimage; - -import com.amaze.filemanager.R; -import com.bumptech.glide.Priority; -import com.bumptech.glide.load.DataSource; -import com.bumptech.glide.load.data.DataFetcher; - -import android.content.Context; -import android.content.pm.PackageInfo; -import android.graphics.drawable.Drawable; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -/** - * @author Emmanuel Messulam on 10/12/2017, at 16:12. - */ -public class ApkImageDataFetcher implements DataFetcher { - - private Context context; - private String model; - - public ApkImageDataFetcher(Context context, String model) { - this.context = context; - this.model = model; - } - - @Override - public void loadData(Priority priority, DataCallback callback) { - PackageInfo pi = context.getPackageManager().getPackageArchiveInfo(model, 0); - Drawable apkIcon; - if (pi != null) { - pi.applicationInfo.sourceDir = model; - pi.applicationInfo.publicSourceDir = model; - apkIcon = pi.applicationInfo.loadIcon(context.getPackageManager()); - } else { - apkIcon = ContextCompat.getDrawable(context, R.drawable.ic_android_white_24dp); - } - callback.onDataReady(apkIcon); - } - - @Override - public void cleanup() { - // Intentionally empty only because we're not opening an InputStream or another I/O resource! - } - - @Override - public void cancel() { - // No cancelation procedure - } - - @NonNull - @Override - public Class getDataClass() { - return Drawable.class; - } - - @NonNull - @Override - public DataSource getDataSource() { - return DataSource.LOCAL; - } -} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.kt new file mode 100644 index 0000000000..212ef4dd57 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.apkimage + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.amaze.filemanager.R +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.data.DataFetcher + +/** + * @author Emmanuel Messulam on 10/12/2017, at 16:12. + */ +class ApkImageDataFetcher(private val context: Context, private val model: String) : + DataFetcher { + override fun loadData( + priority: Priority, + callback: DataFetcher.DataCallback, + ) { + val pi = + context.packageManager.getPackageArchiveInfo( + model, + 0, + ) + val apkIcon: Drawable? + if (pi != null) { + pi.applicationInfo.sourceDir = model + pi.applicationInfo.publicSourceDir = model + apkIcon = pi.applicationInfo.loadIcon(context.packageManager) + } else { + apkIcon = ContextCompat.getDrawable(context, R.drawable.ic_android_white_24dp) + } + callback.onDataReady(apkIcon) + } + + override fun cleanup() { + // Intentionally empty only because we're not opening an InputStream or another I/O resource! + } + + override fun cancel() { + // No cancelation procedure + } + + override fun getDataClass(): Class = Drawable::class.java + + override fun getDataSource(): DataSource = DataSource.LOCAL +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.kt similarity index 55% rename from app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java rename to app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.kt index 6cf0c89355..a54687b1da 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.kt @@ -18,36 +18,32 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.adapters.glide.apkimage; +package com.amaze.filemanager.adapters.glide.apkimage -import com.bumptech.glide.load.Options; -import com.bumptech.glide.load.model.ModelLoader; -import com.bumptech.glide.signature.ObjectKey; - -import android.content.Context; -import android.graphics.drawable.Drawable; - -import androidx.annotation.Nullable; +import android.content.Context +import android.graphics.drawable.Drawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.signature.ObjectKey /** * @author Emmanuel Messulam on 10/12/2017, at 16:06. */ -public class ApkImageModelLoader implements ModelLoader { - - private Context context; - - public ApkImageModelLoader(Context context) { - this.context = context; - } - - @Nullable - @Override - public LoadData buildLoadData(String s, int width, int height, Options options) { - return new LoadData<>(new ObjectKey(s), new ApkImageDataFetcher(context, s)); - } - - @Override - public boolean handles(String s) { - return s.substring(s.length() - 4, s.length()).toLowerCase().equals(".apk"); - } +class ApkImageModelLoader(private val context: Context) : ModelLoader { + override fun buildLoadData( + s: String, + width: Int, + height: Int, + options: Options, + ): ModelLoader.LoadData { + return ModelLoader.LoadData( + ObjectKey(s), + ApkImageDataFetcher( + context, + s, + ), + ) + } + + override fun handles(s: String): Boolean = s.substring(s.length - 4, s.length).lowercase() == ".apk" } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.kt similarity index 59% rename from app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java rename to app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.kt index 45c3df7732..73dc1d2099 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.kt @@ -18,31 +18,20 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.adapters.glide.apkimage; +package com.amaze.filemanager.adapters.glide.apkimage -import com.bumptech.glide.load.model.ModelLoader; -import com.bumptech.glide.load.model.ModelLoaderFactory; -import com.bumptech.glide.load.model.MultiModelLoaderFactory; - -import android.content.Context; -import android.graphics.drawable.Drawable; +import android.content.Context +import android.graphics.drawable.Drawable +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory /** * @author Emmanuel Messulam on 10/12/2017, at 16:21. */ -public class ApkImageModelLoaderFactory implements ModelLoaderFactory { - - private Context context; - - public ApkImageModelLoaderFactory(Context context) { - this.context = context; - } - - @Override - public ModelLoader build(MultiModelLoaderFactory multiFactory) { - return new ApkImageModelLoader(context); - } +class ApkImageModelLoaderFactory(private val context: Context) : + ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = ApkImageModelLoader(context) - @Override - public void teardown() {} + override fun teardown() = Unit } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.kt similarity index 56% rename from app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java rename to app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.kt index caedb7d146..35b8eaaf0a 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.kt @@ -18,29 +18,17 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.adapters.glide.cloudicon; +package com.amaze.filemanager.adapters.glide.cloudicon -import com.bumptech.glide.load.model.ModelLoader; -import com.bumptech.glide.load.model.ModelLoaderFactory; -import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import android.content.Context +import android.graphics.Bitmap +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory -import android.content.Context; -import android.graphics.Bitmap; +/** Created by Vishal Nehra on 3/27/2018. */ +class CloudIconModelFactory(private val context: Context) : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = CloudIconModelLoader(context) -/** Created by Vishal Nehra on 3/27/2018. */ -public class CloudIconModelFactory implements ModelLoaderFactory { - - private Context context; - - public CloudIconModelFactory(Context context) { - this.context = context; - } - - @Override - public ModelLoader build(MultiModelLoaderFactory multiFactory) { - return new CloudIconModelLoader(context); - } - - @Override - public void teardown() {} + override fun teardown() = Unit } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java deleted file mode 100644 index 33edfb754d..0000000000 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai and Contributors. - * - * This file is part of Amaze File Manager. - * - * Amaze File Manager is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.amaze.filemanager.adapters.glide.cloudicon; - -import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX; -import static com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX; - -import com.amaze.filemanager.database.CloudHandler; -import com.bumptech.glide.load.Options; -import com.bumptech.glide.load.model.ModelLoader; -import com.bumptech.glide.signature.ObjectKey; - -import android.content.Context; -import android.graphics.Bitmap; - -import androidx.annotation.Nullable; - -/** Created by Vishal Nehra on 3/27/2018. */ -public class CloudIconModelLoader implements ModelLoader { - - private final Context context; - - public CloudIconModelLoader(Context context) { - this.context = context; - } - - @Nullable - @Override - public LoadData buildLoadData(String s, int width, int height, Options options) { - // we put key as current time since we're not disk caching the images for cloud, - // as there is no way to differentiate input streams returned by different cloud services - // for future instances and they don't expose concrete paths either - return new LoadData<>( - new ObjectKey(System.currentTimeMillis()), - new CloudIconDataFetcher(context, s, width, height)); - } - - @Override - public boolean handles(String s) { - return s.startsWith(CloudHandler.CLOUD_PREFIX_BOX) - || s.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX) - || s.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE) - || s.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE) - || s.startsWith(SMB_URI_PREFIX) - || s.startsWith(SSH_URI_PREFIX); - } -} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt new file mode 100644 index 0000000000..89d96cbd32 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon + +import android.content.Context +import android.graphics.Bitmap +import com.amaze.filemanager.database.CloudHandler +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.signature.ObjectKey + +/** Created by Vishal Nehra on 3/27/2018. */ +class CloudIconModelLoader(private val context: Context) : ModelLoader { + override fun buildLoadData( + s: String, + width: Int, + height: Int, + options: Options, + ): ModelLoader.LoadData { + // we put key as current time since we're not disk caching the images for cloud, + // as there is no way to differentiate input streams returned by different cloud services + // for future instances and they don't expose concrete paths either + return ModelLoader.LoadData( + ObjectKey(System.currentTimeMillis()), + CloudIconDataFetcher(context, s, width, height), + ) + } + + override fun handles(s: String): Boolean { + return ( + s.startsWith(CloudHandler.CLOUD_PREFIX_BOX) || + s.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX) || + s.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE) || + s.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE) || + s.startsWith("smb:/") || + s.startsWith("ssh:/") + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java index 8dddfa8579..7fff6cf1b8 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java @@ -29,6 +29,8 @@ import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_DIVIDERS; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT; import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB; import java.io.File; @@ -175,6 +177,11 @@ public class MainFragment extends Fragment private boolean hideFab = false; + // Track thumbnail-related preference values so we can detect changes on resume + // and force a list reload (LayoutElementParcelable.iconData is set at construction time). + private boolean pausedShowThumb; + private int pausedRemoteThumbMaxSize; + private final ActivityResultLauncher handleDocumentUriForRestrictedDirectories = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), @@ -217,6 +224,12 @@ public void onCreate(Bundle savedInstanceState) { if (getArguments() != null) { hideFab = getArguments().getBoolean(BUNDLE_HIDE_FAB, false); } + + // Initialize thumbnail pref snapshots so the first onResume doesn't see a false change + pausedShowThumb = requireMainActivity().getBoolean(PREFERENCE_SHOW_THUMB); + pausedRemoteThumbMaxSize = + sharedPref.getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT); } @Override @@ -1260,11 +1273,36 @@ public void onResume() { super.onResume(); resumeDecryptOperations(); startFileObserver(); + + // LayoutElementParcelable.iconData is set at construction time based on the thumbnail + // preferences. If the user changed the show-thumbs toggle or the remote-thumbnail + // size cap while we were paused (e.g. from the Settings screen), the cached list items + // carry stale iconData which causes blank or wrong icons. Detect the change and force + // a full list reload so the elements are reconstructed with the current preference values. + boolean currentShowThumb = getBoolean(PREFERENCE_SHOW_THUMB); + int currentRemoteThumbMaxSize = + sharedPref.getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT); + if (currentShowThumb != pausedShowThumb + || currentRemoteThumbMaxSize != pausedRemoteThumbMaxSize) { + if (getCurrentPath() != null) { + mainActivityViewModel.evictPathFromListCache(getCurrentPath()); + } + // Reset the back-button element so it picks up the new PREFERENCE_SHOW_THUMB value + mainFragmentViewModel.setBack(null); + updateList(true); + } } @Override public void onPause() { super.onPause(); + // Snapshot thumbnail-related prefs so onResume can detect changes + pausedShowThumb = getBoolean(PREFERENCE_SHOW_THUMB); + pausedRemoteThumbMaxSize = + sharedPref.getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT); + if (customFileObserver != null) { customFileObserver.stopWatching(); } diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt index 891c66b485..734352f7a7 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt @@ -47,6 +47,7 @@ object PreferencesConstants { // ui_prefs.xml const val PREFERENCE_SHOW_THUMB = "showThumbs" + const val PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE = "showRemoteThumbsMaxSize" const val PREFERENCE_SHOW_FILE_SIZE = "showFileSize" const val PREFERENCE_SHOW_PERMISSIONS = "showPermissions" const val PREFERENCE_SHOW_GOBACK_BUTTON = "goBack_checkbox" @@ -56,6 +57,9 @@ object PreferencesConstants { const val PREFERENCE_DRAG_AND_DROP_REMEMBERED = "dragOperationRemembered" const val PREFERENCE_LANGUAGE = "language" + const val PREFERENCE_SHOW_THUMB_DEFAULT = true + const val PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT = 0 + // drag and drop const val PREFERENCE_DRAG_DEFAULT = 0 const val PREFERENCE_DRAG_TO_SELECT = 1 diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt index 50d8ffb2b5..82e515fc43 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt @@ -21,23 +21,33 @@ package com.amaze.filemanager.ui.fragments.preferencefragments import android.os.Bundle +import android.text.format.Formatter import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.preference.Preference import com.afollestad.materialdialogs.MaterialDialog import com.amaze.filemanager.R +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB_DEFAULT +import com.amaze.filemanager.utils.AppConstants.MEGABYTE import com.amaze.filemanager.utils.getLangPreferenceDropdownEntries class UiPrefsFragment : BasePrefsFragment() { override val title = R.string.ui private var dragAndDropPref: Preference? = null + private var showThumbsRemoteMaxSizePref: Preference? = null + private lateinit var sizes: IntArray override fun onCreatePreferences( savedInstanceState: Bundle?, rootKey: String?, ) { setPreferencesFromResource(R.xml.ui_prefs, rootKey) + sizes = resources.getIntArray(R.array.thumbnailDisplaySizeLimitPreference) findPreference("sidebar_bookmarks")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { @@ -103,7 +113,7 @@ class UiPrefsFragment : BasePrefsFragment() { } val dragToMoveArray = resources.getStringArray(R.array.dragAndDropPreference) - dragAndDropPref = findPreference(PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE) + dragAndDropPref = findPreference(PREFERENCE_DRAG_AND_DROP_PREFERENCE) updateDragAndDropPreferenceSummary() dragAndDropPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { @@ -137,6 +147,58 @@ class UiPrefsFragment : BasePrefsFragment() { dragDialogBuilder.build().show() true } + val showThumbEnabled = + activity.prefs.getBoolean( + PREFERENCE_SHOW_THUMB, + PREFERENCE_SHOW_THUMB_DEFAULT, + ) + showThumbsRemoteMaxSizePref = findPreference(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE) + updateFilePreviewMaxSizeSummary() + findPreference(PREFERENCE_SHOW_THUMB)?.run { + this.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + showThumbsRemoteMaxSizePref?.isEnabled = newValue as Boolean + true + } + } + if (showThumbEnabled) { + showThumbsRemoteMaxSizePref?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + val currentPreference: Int = + activity.prefs.getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT, + ) + MaterialDialog.Builder(activity).theme( + activity.utilsProvider.appTheme.materialDialogTheme, + ).title(R.string.thumb_remote_max_size) + .items( + sizes.mapIndexed { index, it -> + if (index == PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT) { + resources.getString(R.string.no_limit) + } else { + Formatter.formatShortFileSize( + activity, + (MEGABYTE * it).toLong(), + ) + } + }, + ).itemsCallbackSingleChoice(currentPreference) { dialog, _, which, _ -> + activity.prefs.edit() + .putInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, + which, + ) + .apply() + updateFilePreviewMaxSizeSummary() + dialog.dismiss() + true + }.build().show() + true + } + } else { + showThumbsRemoteMaxSizePref?.isEnabled = false + } } private fun updateDragAndDropPreferenceSummary() { @@ -148,4 +210,18 @@ class UiPrefsFragment : BasePrefsFragment() { val dragToMoveArray = resources.getStringArray(R.array.dragAndDropPreference) dragAndDropPref?.summary = dragToMoveArray[value] } + + private fun updateFilePreviewMaxSizeSummary() { + val value = + activity.prefs.getInt( + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, + PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT, + ) + showThumbsRemoteMaxSizePref?.summary = + if (value == PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT) { + resources.getString(R.string.no_limit) + } else { + Formatter.formatShortFileSize(activity, (sizes[value] * MEGABYTE).toLong()) + } + } } diff --git a/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt b/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt index dfaa2d9ffb..fb58cd376d 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt +++ b/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt @@ -24,4 +24,6 @@ object AppConstants { const val NEW_FILE_DELIMITER = "." const val NEW_FILE_EXTENSION_TXT = "txt" const val NEW_LINE = "\n" + const val KILOBYTE = 1024 + const val MEGABYTE = KILOBYTE * KILOBYTE } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 80d49cad49..207326a005 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -78,4 +78,12 @@ @string/protocol_ftps + + -1 + 1 + 4 + 10 + 100 + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11fca688cd..5065877f4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,6 +158,7 @@ General Show Thumbnails of apps and images Show Thumbnails + Show Remote Thumbnail Max File Size Show Hidden Files and Folders Show the date and time of last modification Show Last Modified Date and Time @@ -747,6 +748,7 @@ Touch and drag to move or copy files Touch and drag to select files Disable + No limit Choose operation to perform Remember for next time Grant SAF access for FTP server diff --git a/app/src/main/res/xml/ui_prefs.xml b/app/src/main/res/xml/ui_prefs.xml index 512f88e9e9..50325347cd 100644 --- a/app/src/main/res/xml/ui_prefs.xml +++ b/app/src/main/res/xml/ui_prefs.xml @@ -9,6 +9,11 @@ app:key="showThumbs" app:summary="@string/thumb_summary" app:title="@string/thumb" /> + , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.utils.AppConstants.MEGABYTE +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RecyclerAdapter.shouldLoadThumbnailStatic]. + * + * These are pure JVM tests – no Android runtime is required – because the method under test only + * depends on primitive values and the [OpenMode] enum. The [maxSizes] array mirrors the + * `R.array.thumbnailDisplaySizeLimitPreference` resource: `[-1, 1, 4, 10, 100]` (MB; index 0 + * means "no limit"). + */ +class RecyclerAdapterShouldLoadThumbnailTest { + /** Matches R.array.thumbnailDisplaySizeLimitPreference: [-1, 1, 4, 10, 100] */ + private val maxSizes = intArrayOf(-1, 1, 4, 10, 100) + + // ------------------------------------------------------------------ showThumb = false + + /** + * When the global "show thumbnails" toggle is off, [RecyclerAdapter.shouldLoadThumbnailStatic] + * must return `false` regardless of mode or file size. + */ + @Test + fun testShowThumbFalse_localFile_returnsFalse() { + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + // showThumb = + false, + maxSizes, + // capIndex = + 0, + // longSize = + 100L, + OpenMode.FILE, + ), + ) + } + + @Test + fun testShowThumbFalse_remoteSmallFile_returnsFalse() { + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + // showThumb = + false, + maxSizes, + // capIndex = + 1, // 1 MB cap + // longSize = + 512L, // 512 bytes – well below the cap + OpenMode.SFTP, + ), + ) + } + + // ------------------------------------------------------------------ FTP mode + + /** + * FTPClient is not thread-safe; thumbnails are disabled for FTP unconditionally. + */ + @Test + fun testFtpMode_showThumbTrue_noCap_returnsFalse() { + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + // showThumb = + true, + maxSizes, + // capIndex = + 0, // no cap + // longSize = + 1024L, + OpenMode.FTP, + ), + ) + } + + @Test + fun testFtpMode_showThumbTrue_withCap_smallFile_returnsFalse() { + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + // showThumb = + true, + maxSizes, + // capIndex = + 1, + // longSize = + 100L, + OpenMode.FTP, + ), + ) + } + + // ------------------------------------------------------------------ Local modes + + /** + * Local file-system modes have no size cap – thumbnails are always loaded when showThumb=true. + */ + @Test + fun testLocalFileMode_returnsTrue() { + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + // showThumb = + true, + maxSizes, + // capIndex = + 1, // cap is irrelevant for local files + // longSize = + 500L * MEGABYTE, + OpenMode.FILE, + ), + ) + } + + @Test + fun testRootMode_returnsTrue() { + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + 0, + 1024L, + OpenMode.ROOT, + ), + ) + } + + // ------------------------------------------------------------------ Remote modes: no cap + + /** + * When capIndex == 0 ("No limit"), any file size should load a thumbnail for remote modes. + */ + @Test + fun testSftp_noCap_hugeFile_returnsTrue() { + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 0, + // longSize = + 500L * MEGABYTE, + OpenMode.SFTP, + ), + ) + } + + @Test + fun testSmb_noCap_largeFile_returnsTrue() { + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + 0, + 100L * MEGABYTE, + OpenMode.SMB, + ), + ) + } + + // ------------------------------------------------------------------ Remote modes: with cap + + /** File strictly below the 1 MB cap → thumbnail should load. */ + @Test + fun testSftp_1MbCap_smallFile_returnsTrue() { + val smallFile = 100 * 1024L // 100 KB + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 1, // maxSizes[1] == 1 → 1 MB cap + smallFile, + OpenMode.SFTP, + ), + ) + } + + /** File exactly at the 1 MB cap → thumbnail should load (inclusive <=). */ + @Test + fun testSftp_1MbCap_exactlyAtLimit_returnsTrue() { + val exactly1Mb = 1 * MEGABYTE.toLong() + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 1, + exactly1Mb, + OpenMode.SFTP, + ), + ) + } + + /** File 1 byte above the 1 MB cap → thumbnail must NOT load. */ + @Test + fun testSftp_1MbCap_oneBytePastLimit_returnsFalse() { + val justOver1Mb = 1 * MEGABYTE.toLong() + 1L + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 1, + justOver1Mb, + OpenMode.SFTP, + ), + ) + } + + /** Large file above the 4 MB cap → must not load. */ + @Test + fun testDropbox_4MbCap_largeFile_returnsFalse() { + val fiveMb = 5L * MEGABYTE + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 2, // maxSizes[2] == 4 → 4 MB cap + fiveMb, + OpenMode.DROPBOX, + ), + ) + } + + /** BOX file below the 100 MB cap → should load. */ + @Test + fun testBox_100MbCap_fileWithinCap_returnsTrue() { + val fiftyMb = 50L * MEGABYTE + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 4, // maxSizes[4] == 100 → 100 MB cap + fiftyMb, + OpenMode.BOX, + ), + ) + } + + /** GDRIVE file above the 10 MB cap → must not load. */ + @Test + fun testGdrive_10MbCap_fileAboveCap_returnsFalse() { + val fiftyMb = 50L * MEGABYTE + assertFalse( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 3, // maxSizes[3] == 10 → 10 MB cap + fiftyMb, + OpenMode.GDRIVE, + ), + ) + } + + /** ONEDRIVE file within the 10 MB cap → should load. */ + @Test + fun testOnedrive_10MbCap_fileWithinCap_returnsTrue() { + val fiveMb = 5L * MEGABYTE + assertTrue( + RecyclerAdapter.shouldLoadThumbnailStatic( + true, + maxSizes, + // capIndex = + 3, + fiveMb, + OpenMode.ONEDRIVE, + ), + ) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt new file mode 100644 index 0000000000..fe468cb043 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt @@ -0,0 +1,324 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import android.os.Build +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.P +import android.webkit.MimeTypeMap +import androidx.preference.PreferenceManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.adapters.data.IconDataParcelable.IMAGE_FROMCLOUD +import com.amaze.filemanager.adapters.data.IconDataParcelable.IMAGE_FROMFILE +import com.amaze.filemanager.adapters.data.IconDataParcelable.IMAGE_RES +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.shadows.ShadowMultiDex +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE +import com.amaze.filemanager.utils.AppConstants.MEGABYTE +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + shadows = [ShadowMultiDex::class], +) +class LayoutElementParcelableTest { + @Before + fun setUp() { + // By default Robolectric's MimeTypeMap is empty, we need to populate them + val mimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()) + mimeTypeMap.addExtensionMimeTypMapping("jpg", "image/jpg") + mimeTypeMap.addExtensionMimeTypMapping("apk", "application/vnd.android.package-archive") + // Reset size-cap preference to default (no cap) before each test + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .remove(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE) + .apply() + } + + @After + fun tearDown() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .remove(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE) + .apply() + } + + // ---------------------------------------------------------------- Remote: SFTP + + /** Remote file above the 1 MB size limit → IMAGE_RES (no thumbnail) */ + @Test + fun testConstructorWithBigRemoteFile() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) // idx=1 → 1 MB cap + .apply() + val a = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "17.89 MB", + 17_889_945, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + assertEquals(IMAGE_RES, a.iconData.type) + } + + /** Remote file below the 1 MB size limit → IMAGE_FROMCLOUD */ + @Test + fun testConstructorWithSmallRemoteFile() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) // idx=1 → 1 MB cap + .apply() + val b = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "100 KB", + 102_400, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + assertEquals(IMAGE_FROMCLOUD, b.iconData.type) + } + + /** + * Remote file exactly at the 1 MB size limit → IMAGE_FROMCLOUD. + * Verifies the inclusive (<=) comparison. + */ + @Test + fun testConstructorWithFileExactlyAtSizeLimit() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) // idx=1 → 1 MB cap + .apply() + val exactlyOneMb = (1 * MEGABYTE).toLong() + val c = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "1 MB", + exactlyOneMb, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + assertEquals(IMAGE_FROMCLOUD, c.iconData.type) + } + + /** + * No size cap configured (idx=0, the default) → even a large remote file gets IMAGE_FROMCLOUD. + */ + @Test + fun testConstructorWithNoSizeCapRemoteFile() { + // idx=0 is already the default, but be explicit + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 0) + .apply() + val d = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "test-verify.jpg", + "ssh://127.0.0.1:22222/home/user/test-verify.jpg", + "777", + "", + "500 MB", + 500L * MEGABYTE, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SFTP, + ) + assertEquals(IMAGE_FROMCLOUD, d.iconData.type) + } + + // ---------------------------------------------------------------- Remote: other cloud modes + + /** SMB file within size cap → IMAGE_FROMCLOUD */ + @Test + fun testConstructorWithSmbFileWithinCap() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) + .apply() + val e = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "photo.jpg", + "smb://192.168.1.1/share/photo.jpg", + "777", + "", + "512 KB", + 512 * 1024L, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.SMB, + ) + assertEquals(IMAGE_FROMCLOUD, e.iconData.type) + } + + /** DROPBOX file above size cap → IMAGE_RES */ + @Test + fun testConstructorWithDropboxFileAboveCap() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) + .apply() + val f = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "large.jpg", + "/large.jpg", + "", + "", + "5 MB", + 5L * MEGABYTE, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.DROPBOX, + ) + assertEquals(IMAGE_RES, f.iconData.type) + } + + // ---------------------------------------------------------------- Remote: FTP (no thumbnails) + + /** + * FTP file → always IMAGE_RES regardless of size. + * FTPClient is thread-unsafe so thumbnails are disabled for FTP. + */ + @Test + fun testConstructorWithFtpFileIsAlwaysImageRes() { + // Even with no size cap, FTP must never load thumbnails + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 0) // no cap + .apply() + val g = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "photo.jpg", + "ftp://192.168.1.1/photo.jpg", + "", + "", + "100 KB", + 102_400, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.FTP, + ) + assertEquals(IMAGE_RES, g.iconData.type) + } + + // ---------------------------------------------------------------- Local modes + + /** Local image file → IMAGE_FROMFILE (size cap does not apply to local files) */ + @Test + fun testConstructorWithLocalImageFile() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) // cap set, should be ignored + .apply() + val h = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "local.jpg", + "/sdcard/local.jpg", + "", + "", + "50 MB", + 50L * MEGABYTE, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.FILE, + ) + assertEquals(IMAGE_FROMFILE, h.iconData.type) + } + + /** Local APK file → IMAGE_FROMFILE regardless of size cap */ + @Test + fun testConstructorWithLocalApkFile() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 1) + .apply() + val i = + LayoutElementParcelable( + AppConfig.getInstance(), + false, + "app.apk", + "/sdcard/app.apk", + "", + "", + "30 MB", + 30L * MEGABYTE, + false, + System.currentTimeMillis().toString(), + false, + true, + OpenMode.FILE, + ) + assertEquals(IMAGE_FROMFILE, i.iconData.type) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/AbstractPreferencesFragmentTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/AbstractPreferencesFragmentTest.kt new file mode 100644 index 0000000000..7f833dcf8e --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/AbstractPreferencesFragmentTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.N +import android.os.Build.VERSION_CODES.P +import android.os.storage.StorageManager +import androidx.lifecycle.Lifecycle +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.shadows.ShadowMultiDex +import com.amaze.filemanager.test.ShadowTabHandler +import com.amaze.filemanager.test.TestUtils.initializeInternalStorage +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.activities.PreferencesActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.BasePrefsFragment +import com.amaze.filemanager.ui.fragments.preferencefragments.PrefsFragment +import com.amaze.filemanager.ui.views.preference.CheckBox +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper + +/** + * Convenient test base for [BasePrefsFragment] subclasses. + */ +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [LOLLIPOP, P, VERSION_CODES.R], + shadows = [ShadowMultiDex::class, ShadowTabHandler::class], +) +abstract class AbstractPreferencesFragmentTest(private val key: String) { + /** + * MainActivity required setup. + */ + @Before + fun setUp() { + if (SDK_INT >= N) initializeInternalStorage() + } + + /** + * Post test teardown. + */ + @After + fun tearDown() { + if (SDK_INT >= N) { + Shadows.shadowOf( + ApplicationProvider.getApplicationContext().getSystemService( + StorageManager::class.java, + ), + ).resetStorageVolumeList() + } + } + + /** + * Starts [PreferencesActivity], tap on specified preferences and perform test. + */ + protected fun performTest( + testContent: ( + prefs: SharedPreferences, + preferencesActivity: PreferencesActivity, + prefsFragment: T, + ) -> Unit, + ) { + ActivityScenario.launch(MainActivity::class.java).let { mainScenario -> + ShadowLooper.idleMainLooper() + mainScenario.moveToState(Lifecycle.State.STARTED) + mainScenario.onActivity { mainActivity -> + ActivityScenario.launch( + Intent(mainActivity, PreferencesActivity::class.java), + ).moveToState(Lifecycle.State.STARTED).onActivity { preferencesActivity -> + mainScenario.moveToState(Lifecycle.State.DESTROYED).close() + preferencesActivity.supportFragmentManager.run { + val prefs = + PreferenceManager.getDefaultSharedPreferences( + AppConfig.getInstance(), + ) + val prefsFragment = fragments.first() as PrefsFragment + prefsFragment.findPreference(key)?.performClick() + executePendingTransactions() + val targetFragment = fragments.first() as T + testContent.invoke(prefs, preferencesActivity, targetFragment) + } + } + } + } + } +} + +/** + * Test-only method for quickly finds specified Preference without worrying about nullability. + */ +fun BasePrefsFragment.requirePreference(key: String): Preference { + return findPreference(key) + ?: throw IllegalArgumentException("Preference [$key] not found") +} + +/** + * Test-only method to quickly finds specified Preference and cast it into [CheckBox]. + */ +fun BasePrefsFragment.requireCheckboxPreference(key: String): CheckBox = requirePreference(key) as CheckBox diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentThumbnailPrefChangeTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentThumbnailPrefChangeTest.kt new file mode 100644 index 0000000000..8cc50e2f4c --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/MainFragmentThumbnailPrefChangeTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import com.amaze.filemanager.ui.activities.AbstractMainActivityTestBase +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import org.robolectric.shadows.ShadowLooper + +/** + * Tests that [MainFragment] detects thumbnail-preference changes that occurred while the fragment + * was paused, and forces a full list reload on resume. + * + * The relevant logic lives in [MainFragment.onPause] / [MainFragment.onResume]: + * - [MainFragment.onPause] snapshots [PreferencesConstants.PREFERENCE_SHOW_THUMB] and + * [PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE]. + * - [MainFragment.onResume] compares the current values with the snapshots and, when they differ, + * calls `updateList(true)` which creates a new [MainFragment.loadFilesListTask]. + * + * The tests live in the *same package* as [MainFragment] so they can access the + * package-private `loadFilesListTask` field directly (without reflection). + */ +class MainFragmentThumbnailPrefChangeTest : AbstractMainActivityTestBase() { + private var scenario: ActivityScenario? = null + + @After + override fun tearDown() { + super.tearDown() + scenario?.close() + scenario = null + } + + // ------------------------------------------------------------------ helpers + + /** + * Launches [MainActivity], idles the looper so the fragment is fully set up, and moves to + * [Lifecycle.State.STARTED] (which triggers [MainFragment.onPause]). + * + * Returns the [ActivityScenario] so the caller can continue exercising it. + */ + private fun launchAndPause(): ActivityScenario { + val s = ActivityScenario.launch(MainActivity::class.java) + ShadowLooper.idleMainLooper() + s.moveToState(Lifecycle.State.STARTED) // triggers fragment onPause → snapshots prefs + ShadowLooper.idleMainLooper() + return s + } + + // ------------------------------------------------------------------ tests + + /** + * When [PREFERENCE_SHOW_THUMB] is toggled while the fragment is paused, [MainFragment.onResume] + * should detect the change and create a new [MainFragment.loadFilesListTask]. + */ + @Test + fun testShowThumbChangeTriggersReload() { + scenario = launchAndPause() + + // Record the load task reference before we trigger a reload + var taskBefore: Any? = null + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull("getCurrentMainFragment() returned null", fragment) + taskBefore = fragment!!.loadFilesListTask + + // Flip the showThumbs preference while paused + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val current = prefs.getBoolean(PREFERENCE_SHOW_THUMB, true) + prefs.edit().putBoolean(PREFERENCE_SHOW_THUMB, !current).commit() + } + + // Resume: onResume detects the change and calls updateList(true) + scenario!!.moveToState(Lifecycle.State.RESUMED) + ShadowLooper.idleMainLooper() + + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull(fragment) + assertNotSame( + "A new loadFilesListTask should have been created after PREFERENCE_SHOW_THUMB changed", + taskBefore, + fragment!!.loadFilesListTask, + ) + } + } + + /** + * When [PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE] changes while the fragment is paused, + * [MainFragment.onResume] should detect the change and create a new load task. + */ + @Test + fun testRemoteThumbMaxSizeChangeTriggersReload() { + scenario = launchAndPause() + + var taskBefore: Any? = null + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull(fragment) + taskBefore = fragment!!.loadFilesListTask + + // Change the remote thumbnail size-cap preference while paused + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val current = prefs.getInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE_DEFAULT) + // Toggle between 0 (no cap) and 1 (1 MB cap) + prefs.edit().putInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, if (current == 0) 1 else 0).commit() + } + + scenario!!.moveToState(Lifecycle.State.RESUMED) + ShadowLooper.idleMainLooper() + + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull(fragment) + assertNotSame( + "A new loadFilesListTask should have been created after " + + "PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE changed", + taskBefore, + fragment!!.loadFilesListTask, + ) + } + } + + /** + * When neither thumbnail preference changes between pause and resume, no reload should occur: + * [MainFragment.loadFilesListTask] should remain the same instance. + */ + @Test + fun testNoPreferenceChangeDoesNotTriggerReload() { + scenario = launchAndPause() + + var taskBefore: Any? = null + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull(fragment) + // Record task, but do NOT change any prefs + taskBefore = fragment!!.loadFilesListTask + } + + scenario!!.moveToState(Lifecycle.State.RESUMED) + ShadowLooper.idleMainLooper() + + scenario!!.onActivity { activity -> + val fragment = activity.getCurrentMainFragment() + assertNotNull(fragment) + assertSame( + "loadFilesListTask should not change when no thumbnail preferences changed", + taskBefore, + fragment!!.loadFilesListTask, + ) + } + } +} diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt new file mode 100644 index 0000000000..860939205e --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import android.text.format.Formatter +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_FILE_SIZE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_LAST_MODIFIED +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_PERMISSIONS +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB +import com.amaze.filemanager.ui.fragments.preferencefragments.UiPrefsFragment +import com.amaze.filemanager.utils.AppConstants.MEGABYTE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.robolectric.shadows.ShadowDialog + +/** + * Test [UiPrefsFragment]. + */ +class UiPrefsFragmentTest : AbstractPreferencesFragmentTest("ui") { + /** + * Verify default values. + */ + @Test + fun testDefaultStatuses() { + performTest { _, preferencesActivity, uiPrefsFragment -> + val disabledString = preferencesActivity.getString(R.string.disable) + val noLimitString = preferencesActivity.getString(R.string.no_limit) + uiPrefsFragment.run { + assertTrue(requireCheckboxPreference(PREFERENCE_SHOW_THUMB).isChecked) + requirePreference(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE).run { + assertTrue(this.isEnabled) + assertEquals( + noLimitString, + this.summary.toString(), + ) + } + assertFalse(requireCheckboxPreference(PREFERENCE_SHOW_HIDDENFILES).isChecked) + assertTrue(requireCheckboxPreference(PREFERENCE_SHOW_LAST_MODIFIED).isChecked) + assertTrue(requireCheckboxPreference(PREFERENCE_SHOW_FILE_SIZE).isChecked) + assertFalse(requireCheckboxPreference(PREFERENCE_SHOW_GOBACK_BUTTON).isChecked) + assertEquals( + disabledString, + requirePreference(PREFERENCE_DRAG_AND_DROP_PREFERENCE).summary.toString(), + ) + assertFalse(requireCheckboxPreference(PREFERENCE_SHOW_PERMISSIONS).isChecked) + } + } + } + + @Test + fun testShowThumbnailsCheckbox() { + performTest { prefs, preferencesActivity, prefsFragment -> + prefsFragment.requireCheckboxPreference(PREFERENCE_SHOW_THUMB).performClick() + assertFalse(prefs.getBoolean(PREFERENCE_SHOW_THUMB, true)) + assertFalse( + prefsFragment.requirePreference(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE).isEnabled, + ) + } + } + + @Test + fun testShowRemoteThumbnailsMaxSizeOptions() { + val presetItems = + AppConfig.getInstance().resources + .getIntArray(R.array.thumbnailDisplaySizeLimitPreference) + performTest { prefs, preferencesActivity, prefsFragment -> + prefsFragment.requirePreference(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE).performClick() + assertEquals(1, ShadowDialog.getShownDialogs().size) + assertTrue(ShadowDialog.getLatestDialog() is MaterialDialog) + (ShadowDialog.getLatestDialog() as MaterialDialog).let { dialog -> + assertEquals(presetItems.size, dialog.items?.size) + dialog.items?.forEachIndexed { index, value -> + if (index == 0) { + assertEquals(AppConfig.getInstance().getString(R.string.no_limit), value) + } else { + assertEquals( + Formatter.formatShortFileSize( + AppConfig.getInstance(), + (presetItems[index] * MEGABYTE).toLong(), + ), + value, + ) + } + } ?: fail("No item available!?") + dialog.view + } + } + } +} diff --git a/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamSourceTest.java b/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamSourceTest.java index c6d3bcee74..9d9a32a369 100644 --- a/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamSourceTest.java +++ b/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamSourceTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.fileoperations.filesystem.cloud; -import static android.os.Build.VERSION_CODES.KITKAT; +import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -50,7 +50,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {KITKAT, P}) + sdk = {LOLLIPOP, P}) public class CloudStreamSourceTest { private CloudStreamSource cs; private String testFilePath; From 499fccdf1c13976b0f1e0b11a03040ae84725cff Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 8 Apr 2026 00:15:10 +0800 Subject: [PATCH 2/3] Updates to cloud icon fetching to reduce redundant calls --- .../filemanager/adapters/RecyclerAdapter.java | 63 +++-- .../glide/RecyclerPreloadModelProvider.java | 2 +- .../glide/cloudicon/CloudIconDataFetcher.kt | 85 +++++- .../glide/cloudicon/CloudIconModelLoader.kt | 10 +- .../adapters/RecyclerAdapterListItemTest.kt | 203 ++++++++++++++ .../cloudicon/CloudIconDataFetcherTest.kt | 259 ++++++++++++++++++ .../cloudicon/CloudIconModelLoaderTest.kt | 161 +++++++++++ 7 files changed, 746 insertions(+), 37 deletions(-) create mode 100644 app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt diff --git a/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java index 777cc8f246..8ff7e2e540 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java @@ -171,6 +171,15 @@ public class RecyclerAdapter extends RecyclerView.Adapter elements, boolean invalidate) { + refreshThumbnailPreferences(); + if (preloader != null) { recyclerView.removeOnScrollListener(preloader); preloader = null; @@ -796,6 +808,9 @@ private void bindViewHolderList(@NonNull final ItemViewHolder holder, int positi final LayoutElementParcelable rowItem = getItemsDigested().get(position).layoutElementParcelable; + // Compute once per bind to avoid repeated resource/SharedPreferences reads + final boolean shouldLoad = shouldLoadThumbnail(rowItem.longSize, rowItem.getMode()); + if (mainFragment.getMainFragmentViewModel() != null && position == getItemCount() - 1) { holder.baseItemView.setMinimumHeight((int) minRowHeight); if (getItemsDigested().size() == (getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON) ? 1 : 0)) @@ -907,7 +922,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() switch (rowItem.filetype) { case Icons.IMAGE: case Icons.VIDEO: - if (shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { + if (shouldLoad) { if (getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES)) { showThumbnailWithBackground( holder, rowItem.iconData, holder.pictureIcon, rowItem.iconData::setImageBroken); @@ -923,7 +938,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() } break; case Icons.APK: - if (shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { + if (shouldLoad) { showThumbnailWithBackground( holder, rowItem.iconData, holder.apkIcon, rowItem.iconData::setImageBroken); } else { @@ -966,7 +981,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() if ((rowItem.filetype != Icons.IMAGE && rowItem.filetype != Icons.APK && rowItem.filetype != Icons.VIDEO) - || !shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { + || !shouldLoad) { holder.apkIcon.setVisibility(View.GONE); holder.pictureIcon.setVisibility(View.GONE); holder.genericIcon.setVisibility(View.VISIBLE); @@ -980,7 +995,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() if (!((rowItem.filetype == Icons.APK || rowItem.filetype == Icons.IMAGE || rowItem.filetype == Icons.VIDEO) - && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode()))) { + && shouldLoad)) { holder.genericIcon.setVisibility(View.VISIBLE); GradientDrawable gradientDrawable = (GradientDrawable) holder.genericIcon.getBackground(); @@ -1021,6 +1036,9 @@ private void bindViewHolderGrid(@NonNull final ItemViewHolder holder, int positi final LayoutElementParcelable rowItem = getItemsDigested().get(position).layoutElementParcelable; + // Compute once per bind to avoid repeated resource/SharedPreferences reads + final boolean shouldLoad = shouldLoadThumbnail(rowItem.longSize, rowItem.getMode()); + holder.baseItemView.setOnLongClickListener( p1 -> { if (hasPendingPasteOperation()) return false; @@ -1056,8 +1074,7 @@ && getItemsDigested().get(holder.getAdapterPosition()).getChecked() holder.checkImageViewGrid.setVisibility(View.INVISIBLE); if (rowItem.filetype == Icons.IMAGE || rowItem.filetype == Icons.VIDEO) { - if (getBoolean(PREFERENCE_SHOW_THUMB) - && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { + if (shouldLoad) { holder.imageView1.setVisibility(View.VISIBLE); holder.imageView1.setImageDrawable(null); if (utilsProvider.getAppTheme().equals(AppTheme.DARK) @@ -1066,7 +1083,6 @@ && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { showRoundedThumbnail( holder, rowItem.longSize, - rowItem.getMode(), rowItem.iconData, holder.imageView1, rowItem.iconData::setImageBroken); @@ -1076,12 +1092,10 @@ && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { else holder.genericIcon.setImageResource(R.drawable.ic_doc_video_am); } } else if (rowItem.filetype == Icons.APK) { - if (getBoolean(PREFERENCE_SHOW_THUMB) - && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) + if (shouldLoad) showRoundedThumbnail( holder, rowItem.longSize, - rowItem.getMode(), rowItem.iconData, holder.genericIcon, rowItem.iconData::setImageBroken); @@ -1100,8 +1114,7 @@ && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) } else { switch (rowItem.filetype) { case Icons.VIDEO: - if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) - iconBackground.setBackgroundColor(videoColor); + if (!shouldLoad) iconBackground.setBackgroundColor(videoColor); break; case Icons.AUDIO: iconBackground.setBackgroundColor(audioColor); @@ -1122,12 +1135,10 @@ && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) iconBackground.setBackgroundColor(genericColor); break; case Icons.APK: - if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) - iconBackground.setBackgroundColor(apkColor); + if (!shouldLoad) iconBackground.setBackgroundColor(apkColor); break; case Icons.IMAGE: - if (!shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) - iconBackground.setBackgroundColor(videoColor); + if (!shouldLoad) iconBackground.setBackgroundColor(videoColor); break; default: iconBackground.setBackgroundColor(iconSkinColor); @@ -1146,7 +1157,7 @@ && shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) if ((rowItem.filetype != Icons.IMAGE && rowItem.filetype != Icons.APK && rowItem.filetype != Icons.VIDEO) - || !shouldLoadThumbnail(rowItem.longSize, rowItem.getMode())) { + || !shouldLoad) { View iconBackground = getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) ? holder.genericIcon : holder.iconLayout; iconBackground.setBackgroundColor(goBackColor); @@ -1196,7 +1207,7 @@ public int getCorrectView(IconDataParcelable item, int adapterPosition) { if (mainFragment.getMainFragmentViewModel() != null && mainFragment.getMainFragmentViewModel().isList()) { - if (getBoolean(PREFERENCE_SHOW_THUMB)) { + if (cachedShowThumb) { int filetype = getItemsDigested().get(adapterPosition).requireLayoutElementParcelable().filetype; @@ -1365,7 +1376,6 @@ public boolean onResourceReady( private void showRoundedThumbnail( ItemViewHolder viewHolder, long longSize, - OpenMode mode, IconDataParcelable iconData, AppCompatImageView view, OnImageProcessed errorListener) { @@ -1541,11 +1551,16 @@ private boolean getBoolean(String key) { } private boolean shouldLoadThumbnail(long longSize, OpenMode mode) { - int[] maxSizes = - preferenceActivity.getResources().getIntArray(R.array.thumbnailDisplaySizeLimitPreference); - int idx = preferenceActivity.getPrefs().getInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 0); return shouldLoadThumbnailStatic( - getBoolean(PREFERENCE_SHOW_THUMB), maxSizes, idx, longSize, mode); + cachedShowThumb, cachedMaxSizes, cachedCapIndex, longSize, mode); + } + + /** Re-reads preference / resource values into the cached fields. */ + private void refreshThumbnailPreferences() { + cachedShowThumb = getBoolean(PREFERENCE_SHOW_THUMB); + cachedMaxSizes = + preferenceActivity.getResources().getIntArray(R.array.thumbnailDisplaySizeLimitPreference); + cachedCapIndex = preferenceActivity.getPrefs().getInt(PREFERENCE_SHOW_REMOTE_THUMB_MAX_SIZE, 0); } /** @@ -1658,7 +1673,7 @@ public void toggleShouldToggleDragChecked() { } public void setAnimate(boolean animating) { - if (specialType == -1) this.animate = animating; + if (specialType == TYPE_ITEM || specialType == TYPE_BACK) this.animate = animating; } public boolean getAnimating() { diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java index 827260a010..680841fbb5 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java @@ -73,7 +73,7 @@ public RequestBuilder getPreloadRequestBuilder(IconDataParcelable icon requestBuilder = request.load(iconData.path); break; case IconDataParcelable.IMAGE_FROMCLOUD: - requestBuilder = request.load(iconData.path).diskCacheStrategy(DiskCacheStrategy.NONE); + requestBuilder = request.load(iconData.path).diskCacheStrategy(DiskCacheStrategy.RESOURCE); break; default: requestBuilder = request.load(iconData.image); diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt index 8e95b1f03e..a4efc39c1a 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt @@ -30,6 +30,7 @@ import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.data.DataFetcher import java.io.IOException import java.io.InputStream +import java.util.concurrent.atomic.AtomicBoolean class CloudIconDataFetcher( private val context: Context, @@ -39,22 +40,80 @@ class CloudIconDataFetcher( ) : DataFetcher { companion object { private val TAG = CloudIconDataFetcher::class.java.simpleName + + /** + * Calculate an [BitmapFactory.Options.inSampleSize] value that keeps the + * decoded bitmap larger than the requested [reqWidth]×[reqHeight] while + * still reducing memory usage significantly for oversized sources. + */ + @JvmStatic + internal fun calculateInSampleSize( + outWidth: Int, + outHeight: Int, + reqWidth: Int, + reqHeight: Int, + ): Int { + var inSampleSize = 1 + if (outHeight > reqHeight || outWidth > reqWidth) { + val halfHeight = outHeight / 2 + val halfWidth = outWidth / 2 + while (halfHeight / inSampleSize >= reqHeight && + halfWidth / inSampleSize >= reqWidth + ) { + inSampleSize *= 2 + } + } + return inSampleSize + } } private var inputStream: InputStream? = null + private val cancelled = AtomicBoolean(false) override fun loadData( priority: Priority, callback: DataFetcher.DataCallback, ) { - inputStream = CloudUtil.getThumbnailInputStreamForCloud(context, path) - val options = - BitmapFactory.Options().also { - it.outWidth = width - it.outHeight = height + try { + inputStream = CloudUtil.getThumbnailInputStreamForCloud(context, path) + if (inputStream == null || cancelled.get()) { + callback.onDataReady(null) + return + } + + // Buffer the full stream so we can do a two-pass decode. + // Pass 1 reads only the dimensions; pass 2 decodes with inSampleSize. + val bytes = inputStream!!.readBytes() + if (cancelled.get()) { + callback.onDataReady(null) + return + } + + // --- Pass 1: decode bounds only --- + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) + + // --- Pass 2: decode with appropriate down-sampling --- + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = + calculateInSampleSize( + boundsOptions.outWidth, + boundsOptions.outHeight, + width, + height, + ) + } + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) + callback.onDataReady(bitmap) + } catch (e: Exception) { + if (cancelled.get()) { + callback.onDataReady(null) + } else { + Log.e(TAG, "Error loading cloud icon for $path", e) + callback.onLoadFailed(e) } - val drawable = BitmapFactory.decodeStream(inputStream, null, options) - callback.onDataReady(drawable) + } } override fun cleanup() { @@ -65,7 +124,17 @@ class CloudIconDataFetcher( } } - override fun cancel() = Unit + override fun cancel() { + cancelled.set(true) + // Close the stream to interrupt any in-progress network read so the + // background thread doesn't keep downloading a file whose result will + // never be used. + try { + inputStream?.close() + } catch (_: IOException) { + // Best-effort; the stream may already be closed. + } + } override fun getDataClass(): Class = Bitmap::class.java diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt index 89d96cbd32..b79b42acc8 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.kt @@ -35,11 +35,13 @@ class CloudIconModelLoader(private val context: Context) : ModelLoader { - // we put key as current time since we're not disk caching the images for cloud, - // as there is no way to differentiate input streams returned by different cloud services - // for future instances and they don't expose concrete paths either + // Use the path as the cache key so Glide's memory (and disk) cache can + // recognise repeated loads of the same remote file and serve them from + // cache instead of re-downloading. The previous implementation used + // System.currentTimeMillis() which made every request unique, defeating + // all caching and causing repeated full-file downloads on every bind. return ModelLoader.LoadData( - ObjectKey(System.currentTimeMillis()), + ObjectKey(s), CloudIconDataFetcher(context, s, width, height), ) } diff --git a/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt new file mode 100644 index 0000000000..b856f63f00 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters + +import com.amaze.filemanager.adapters.RecyclerAdapter.EMPTY_LAST_ITEM +import com.amaze.filemanager.adapters.RecyclerAdapter.ListItem +import com.amaze.filemanager.adapters.RecyclerAdapter.TYPE_HEADER_FILES +import com.amaze.filemanager.adapters.RecyclerAdapter.TYPE_HEADER_FOLDERS +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RecyclerAdapter.ListItem.setAnimate] and [RecyclerAdapter.ListItem.getAnimating]. + * + * Before the fix, `setAnimate()` contained the guard `if (specialType == -1)`. Since no + * [ListItem] type constant equals -1, this condition was **always false** and the animate flag + * was never set. As a result, `getAnimating()` always returned `false`, causing the fade-in + * animation to fire on every single `onBindViewHolder` call (even for already-visible rows). + * + * After the fix the guard is `if (specialType == TYPE_ITEM || specialType == TYPE_BACK)`, which + * is the correct set of item types that represent real files and should animate. + */ +class RecyclerAdapterListItemTest { + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun makeItem(): ListItem { + val parcelable = mockk(relaxed = true) + return ListItem(parcelable) // TYPE_ITEM + } + + private fun makeBackItem(): ListItem { + val parcelable = mockk(relaxed = true) + return ListItem(true, parcelable) // TYPE_BACK + } + + private fun makeSpecialItem(type: Int): ListItem = ListItem(type) + + /** + * A fresh [ListItem] must have `getAnimating() == false` before any call to `setAnimate`. + */ + @Test + fun testGetAnimating_defaultFalse_typeItem() { + assertFalse(makeItem().animating) + } + + /** + * The ".." (back) entry is a real navigable item and must support animation, + * but it should start with the flag unset until `setAnimate(true)` is called. + */ + @Test + fun testGetAnimating_defaultFalse_typeBack() { + assertFalse(makeBackItem().animating) + } + + /** + * For a regular file item (TYPE_ITEM), `setAnimate(true)` must enable the flag. + */ + @Test + fun testSetAnimate_true_typeItem_flagIsTrue() { + val item = makeItem() + item.setAnimate(true) + assertTrue( + "setAnimate(true) on TYPE_ITEM must set the animate flag", + item.animating, + ) + } + + /** + * After setting to true, `setAnimate(false)` must reset the flag. + */ + @Test + fun testSetAnimate_falseAfterTrue_typeItem_flagIsFalse() { + val item = makeItem() + item.setAnimate(true) + item.setAnimate(false) + assertFalse( + "setAnimate(false) on TYPE_ITEM must clear the animate flag", + item.animating, + ) + } + + /** + * The ".." (back) entry is a real navigable item and must support animation. + */ + @Test + fun testSetAnimate_true_typeBack_flagIsTrue() { + val item = makeBackItem() + item.setAnimate(true) + assertTrue( + "setAnimate(true) on TYPE_BACK must set the animate flag", + item.animating, + ) + } + + /** + * After setting to true, `setAnimate(false)` must reset the flag for TYPE_BACK as well. + */ + @Test + fun testSetAnimate_falseAfterTrue_typeBack_flagIsFalse() { + val item = makeBackItem() + item.setAnimate(true) + item.setAnimate(false) + assertFalse(item.animating) + } + + /** + * Section headers (TYPE_HEADER_FOLDERS) are not real file entries; they must + * never carry an animation flag regardless of what is passed to `setAnimate`. + * + * This is the regression test for the original bug: before the fix the guard was + * `specialType == -1` (always false), meaning headers *would* have had their flag + * set if the check was intended to restrict rather than allow. The corrected guard + * restricts animation to TYPE_ITEM and TYPE_BACK only. + */ + @Test + fun testSetAnimate_true_typeHeaderFolders_remainsFalse() { + val item = makeSpecialItem(TYPE_HEADER_FOLDERS) + item.setAnimate(true) + assertFalse( + "setAnimate(true) on TYPE_HEADER_FOLDERS must be a no-op", + item.animating, + ) + } + + @Test + fun testSetAnimate_true_typeHeaderFiles_remainsFalse() { + val item = makeSpecialItem(TYPE_HEADER_FILES) + item.setAnimate(true) + assertFalse( + "setAnimate(true) on TYPE_HEADER_FILES must be a no-op", + item.animating, + ) + } + + /** + * The empty last item is a special non-file entry that must never carry an animation flag. + */ + @Test + fun testSetAnimate_true_emptyLastItem_remainsFalse() { + val item = makeSpecialItem(EMPTY_LAST_ITEM) + item.setAnimate(true) + assertFalse( + "setAnimate(true) on EMPTY_LAST_ITEM must be a no-op", + item.animating, + ) + } + + /** + * The types for which [ListItem.setAnimate] works must be exactly those for which + * [ListItem.specialTypeHasFile] returns `true`. This confirms the two methods + * share the same allowed set (TYPE_ITEM and TYPE_BACK). + */ + @Test + fun testSetAnimate_andSpecialTypeHasFile_sameTypesAllowed() { + val fileItem = makeItem() + val backItem = makeBackItem() + val headerFolders = makeSpecialItem(TYPE_HEADER_FOLDERS) + val headerFiles = makeSpecialItem(TYPE_HEADER_FILES) + val emptyLast = makeSpecialItem(EMPTY_LAST_ITEM) + + // Both animate and specialTypeHasFile return true for TYPE_ITEM and TYPE_BACK + for (item in listOf(fileItem, backItem)) { + item.setAnimate(true) + assertTrue( + "Items that specialTypeHasFile() should also support setAnimate()", + item.specialTypeHasFile() && item.animating, + ) + } + + // Neither animate nor specialTypeHasFile is true for headers and empty items + for (item in listOf(headerFolders, headerFiles, emptyLast)) { + item.setAnimate(true) + assertFalse( + "Items that !specialTypeHasFile() should not support setAnimate()", + item.animating, + ) + assertFalse(item.specialTypeHasFile()) + } + } +} diff --git a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt new file mode 100644 index 0000000000..1ff338394a --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon + +import android.graphics.Bitmap +import com.amaze.filemanager.adapters.glide.cloudicon.CloudIconDataFetcher.Companion.calculateInSampleSize +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.InputStream + +/** + * Unit tests for [CloudIconDataFetcher]. + * + * Covers: + * - [CloudIconDataFetcher.Companion.calculateInSampleSize] — pure arithmetic, no Android runtime needed. + * - [CloudIconDataFetcher.cancel] — sets the cancelled flag so a subsequent [loadData] returns null. + */ +class CloudIconDataFetcherTest { + // ------------------------------------------------------------------------- + // calculateInSampleSize + // ------------------------------------------------------------------------- + + /** + * When the source image is exactly the requested size, no down-sampling is needed. + */ + @Test + fun testCalculateInSampleSize_exactMatch_returns1() { + assertEquals(1, calculateInSampleSize(100, 100, 100, 100)) + } + + /** + * When the source is smaller than requested, no down-sampling should occur. + */ + @Test + fun testCalculateInSampleSize_sourceSmaller_returns1() { + assertEquals(1, calculateInSampleSize(50, 50, 100, 100)) + } + + /** + * Source is exactly 2× the requested size → inSampleSize should be 2. + * halfHeight/inSampleSize = 100/1 = 100 ≥ 100, so loop fires once. + */ + @Test + fun testCalculateInSampleSize_double_returns2() { + assertEquals(2, calculateInSampleSize(200, 200, 100, 100)) + } + + /** + * Source is exactly 4× the requested size → inSampleSize should be 4. + */ + @Test + fun testCalculateInSampleSize_quadruple_returns4() { + assertEquals(4, calculateInSampleSize(400, 400, 100, 100)) + } + + /** + * Source is 8× the requested size → inSampleSize should be 8. + */ + @Test + fun testCalculateInSampleSize_8x_returns8() { + assertEquals(8, calculateInSampleSize(800, 800, 100, 100)) + } + + /** + * Non-power-of-two source: 300×300 requesting 100×100. + * halfHeight = 150, halfWidth = 150. + * Loop: 150/1=150 ≥ 100 → inSampleSize=2; 150/2=75 ≥ 100 → false → stop. + * Expected: 2 + */ + @Test + fun testCalculateInSampleSize_300x300_req100x100_returns2() { + assertEquals(2, calculateInSampleSize(300, 300, 100, 100)) + } + + /** + * Landscape source 1000×500 requesting 100×100. + * The narrower dimension (height=500, half=250) is the limiting axis. + * halfHeight=250, halfWidth=500. + * Loop 1: 250/1≥100 && 500/1≥100 → true → inSampleSize=2 + * Loop 2: 250/2=125≥100 && 500/2=250≥100 → true → inSampleSize=4 + * Loop 3: 250/4=62≥100 → false → stop. + * Expected: 4 + */ + @Test + fun testCalculateInSampleSize_landscape_1000x500_req100_returns4() { + assertEquals(4, calculateInSampleSize(1000, 500, 100, 100)) + } + + /** + * Tall source 500×1000 requesting 100×100. + * Same as above but axes swapped — result must be symmetric. + */ + @Test + fun testCalculateInSampleSize_portrait_500x1000_req100_returns4() { + assertEquals(4, calculateInSampleSize(500, 1000, 100, 100)) + } + + /** + * Very large source (20 MP typical photo) requesting thumbnail size 512×512. + * Source: 5000×4000 → halfH=2000, halfW=2500 + * Loop: + * 2000/1≥512 && 2500/1≥512 → true → 2 + * 2000/2=1000≥512 → true → 4 + * 2000/4=500≥512 → false → stop + * Expected: 4 + */ + @Test + fun testCalculateInSampleSize_20mpPhoto_req512_returns4() { + assertEquals(4, calculateInSampleSize(5000, 4000, 512, 512)) + } + + /** + * When one dimension equals the requested size and the other is larger, the larger + * dimension alone should NOT force down-sampling; both axes must exceed the request. + * Source: 1000×100 requesting 100×100. + * halfH=50, halfW=500 → 50/1=50 ≥ 100 → false immediately → inSampleSize stays 1. + */ + @Test + fun testCalculateInSampleSize_oneAxisExact_returns1() { + assertEquals(1, calculateInSampleSize(1000, 100, 100, 100)) + } + + /** + * Zero source dimensions should not crash and should return 1 (no down-sampling). + */ + @Test + fun testCalculateInSampleSize_zeroDimensions_returns1() { + assertEquals(1, calculateInSampleSize(0, 0, 100, 100)) + } + + // ------------------------------------------------------------------------- + // cancel() / cancelled-flag behaviour + // ------------------------------------------------------------------------- + + /** + * After [CloudIconDataFetcher.cancel] is called, the fetcher must close the stream + * without throwing. This verifies the close path in cancel() does not propagate exceptions. + */ + @Test + fun testCancel_closesStreamWithoutException() { + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) + + // Should not throw even when there is no active stream. + fetcher.cancel() + } + + /** + * After [CloudIconDataFetcher.cancel] is called, the stream (if already assigned) + * gets closed. We inject a tracked InputStream by making cancel() close whatever + * is currently stored; here we verify the AtomicBoolean side-effect by confirming + * the stream close is attempted. + */ + @Test + fun testCancel_closesAssignedStream() { + val closed = mutableListOf() + val trackingStream = + object : InputStream() { + override fun read(): Int = -1 + + override fun close() { + super.close() + closed += true + } + } + + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "ssh://host/file.jpg", 100, 100) + + // Reflectively inject the stream to simulate mid-download cancel + val field = CloudIconDataFetcher::class.java.getDeclaredField("inputStream") + field.isAccessible = true + field.set(fetcher, trackingStream) + + fetcher.cancel() + + assertEquals("cancel() must close the injected stream", 1, closed.size) + } + + // ------------------------------------------------------------------------- + // cleanup() + // ------------------------------------------------------------------------- + + /** + * [CloudIconDataFetcher.cleanup] must close the stream without crashing when no + * stream is set. + */ + @Test + fun testCleanup_noStream_doesNotThrow() { + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) + fetcher.cleanup() // must not throw + } + + /** + * [CloudIconDataFetcher.cleanup] must close an assigned stream. + */ + @Test + fun testCleanup_closesAssignedStream() { + val closed = mutableListOf() + val trackingStream = + object : InputStream() { + override fun read(): Int = -1 + + override fun close() { + super.close() + closed += true + } + } + + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) + + val field = CloudIconDataFetcher::class.java.getDeclaredField("inputStream") + field.isAccessible = true + field.set(fetcher, trackingStream) + + fetcher.cleanup() + + assertEquals("cleanup() must close the stream", 1, closed.size) + } + + // ------------------------------------------------------------------------- + // getDataClass / getDataSource contract + // ------------------------------------------------------------------------- + + @Test + fun testGetDataClass_returnsBitmapClass() { + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) + assertEquals(Bitmap::class.java, fetcher.dataClass) + } + + @Test + fun testGetDataSource_returnsRemote() { + val context = mockk(relaxed = true) + val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) + assertEquals(com.bumptech.glide.load.DataSource.REMOTE, fetcher.dataSource) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt new file mode 100644 index 0000000000..f50aee8f3b --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon + +import com.amaze.filemanager.database.CloudHandler +import com.bumptech.glide.load.Options +import com.bumptech.glide.signature.ObjectKey +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [CloudIconModelLoader]. + * + * Covers: + * - [CloudIconModelLoader.handles] — only cloud/SMB/SFTP paths should return `true`. + * - [CloudIconModelLoader.buildLoadData] — the cache [com.bumptech.glide.load.Key] must be + * stable (same path → same key; different paths → different keys). + * Before the fix the key was `ObjectKey(System.currentTimeMillis())` making every + * request unique and defeating Glide's memory/disk cache. + */ +class CloudIconModelLoaderTest { + private val options = mockk(relaxed = true) + private val context = mockk(relaxed = true) + private val loader = CloudIconModelLoader(context) + + + /** + * Only paths that look like cloud storage URLs should return `true` + */ + @Test + fun `Handles cloud storage paths should return true`() { + assertTrue(loader.handles("smb://192.168.1.1/share/photo.jpg")) + assertTrue(loader.handles("ssh://user@host/home/user/photo.jpg")) + assertTrue(loader.handles("${CloudHandler.CLOUD_PREFIX_DROPBOX}photo.jpg")) + assertTrue(loader.handles("${CloudHandler.CLOUD_PREFIX_BOX}photo.jpg")) + assertTrue(loader.handles("${CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE}photo.jpg")) + assertTrue(loader.handles("${CloudHandler.CLOUD_PREFIX_ONE_DRIVE}photo.jpg")) + } + + /** + * Local file paths and other non-cloud URLs should return `false` + */ + @Test + fun `Local absolute path should return false`() { + assertFalse(loader.handles("/storage/emulated/0/DCIM/photo.jpg")) + } + + /** + * Relative paths (without a leading slash) should also return `false` since they don't match + * the expected cloud URL patterns. + */ + @Test + fun `Local relative path should return false`() { + assertFalse(loader.handles("DCIM/photo.jpg")) + } + + /** + * An empty string is not a valid path and should return `false`. + */ + @Test + fun `Empty string should return false`() { + assertFalse(loader.handles("")) + } + + /** + * FTP paths should also return `false`. + */ + @Test + fun `FTP path should return false`() { + assertFalse(loader.handles("ftp://host/file.jpg")) + } + + // ------------------------------------------------------------------------- + // buildLoadData() — stable cache key contract + // ------------------------------------------------------------------------- + + /** + * The same path presented twice must produce the same cache [com.bumptech.glide.load.Key]. + * + * Before the fix, `ObjectKey(System.currentTimeMillis())` was used, guaranteeing a unique key + * on every call. This test would have failed with the old code. + */ + @Test + fun `test buildLoadData twice with the same key`() { + val path = "smb://192.168.1.1/share/photo.jpg" + val key1 = loader.buildLoadData(path, 200, 200, options).sourceKey + val key2 = loader.buildLoadData(path, 200, 200, options).sourceKey + assertEquals( + "Cache key must be stable across identical calls to buildLoadData()", + key1, + key2, + ) + } + + /** + * Different paths must produce different cache keys so distinct files are stored separately. + */ + @Test + fun `test buildLoadData with different paths and keys`() { + val key1 = loader.buildLoadData("smb://host/fileA.jpg", 200, 200, options).sourceKey + val key2 = loader.buildLoadData("smb://host/fileB.jpg", 200, 200, options).sourceKey + assertNotEquals( + "Different paths must produce different cache keys", + key1, + key2, + ) + } + + /** + * The fetcher embedded in [com.bumptech.glide.load.model.ModelLoader.LoadData] must be a + * [CloudIconDataFetcher] — not null and of the correct type. + */ + @Test + fun testBuildLoadData_fetcherIsCloudIconDataFetcher() { + val loadData = loader.buildLoadData("smb://host/photo.jpg", 200, 200, options) + assertNotNull(loadData.fetcher) + assertTrue( + "Fetcher should be a CloudIconDataFetcher", + loadData.fetcher is CloudIconDataFetcher, + ) + } + + /** + * The cache key must equal a plain [ObjectKey] built from the same path string. + * This pins the exact key type so an accidental regression would be caught. + */ + @Test + fun testBuildLoadData_keyEqualsObjectKeyOfPath() { + val path = "ssh://user@host/home/photo.jpg" + val actualKey = loader.buildLoadData(path, 100, 100, options).sourceKey + val expectedKey = ObjectKey(path) + assertEquals( + "Key should be ObjectKey(path), not ObjectKey(currentTimeMillis())", + expectedKey, + actualKey, + ) + } +} From a4a56742944506998040a555450271dc4267f40f Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 11 Apr 2026 09:20:01 +0800 Subject: [PATCH 3/3] Changes per PR feedback, fix formatting and codacy --- .../LayoutElementParcelableEspressoTest.kt | 8 ++++++ .../glide/cloudicon/CloudIconDataFetcher.kt | 1 + .../adapters/RecyclerAdapterListItemTest.kt | 4 +++ .../RecyclerAdapterShouldLoadThumbnailTest.kt | 22 ++++++++++------ .../data/LayoutElementParcelableTest.kt | 7 ++++++ .../cloudicon/CloudIconDataFetcherTest.kt | 25 +++++++++++-------- .../cloudicon/CloudIconModelLoaderTest.kt | 1 - .../ui/fragments/UiPrefsFragmentTest.kt | 6 +++++ file_operations/build.gradle | 6 +++++ 9 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt index bd8b7d6d18..234ee141e5 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableEspressoTest.kt @@ -31,6 +31,10 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LayoutElementParcelableEspressoTest { + /** + * Test constructor of [LayoutElementParcelable] with a big remote file (size > 10 MB) + * and verify that the icon type is set to [IMAGE_RES]. + */ @Test fun testConstructorWithBigRemoteFile() { val a = @@ -54,6 +58,10 @@ class LayoutElementParcelableEspressoTest { } } + /** + * Test constructor of [LayoutElementParcelable] with a small remote file (size <= 10 MB) + * and verify that the icon type is set to [IMAGE_FROMCLOUD]. + */ @Test fun testConstructorWithSmallRemoteFile() { val b = diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt index a4efc39c1a..fedeb271f4 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt @@ -70,6 +70,7 @@ class CloudIconDataFetcher( private var inputStream: InputStream? = null private val cancelled = AtomicBoolean(false) + @Suppress("TooGenericExceptionCaught") override fun loadData( priority: Priority, callback: DataFetcher.DataCallback, diff --git a/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt index b856f63f00..c6054a8b9d 100644 --- a/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt +++ b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterListItemTest.kt @@ -145,6 +145,10 @@ class RecyclerAdapterListItemTest { ) } + /** + * Section headers (TYPE_HEADER_FILES) are not real file entries; they must + * never carry an animation flag regardless of what is passed to `setAnimate`. + */ @Test fun testSetAnimate_true_typeHeaderFiles_remainsFalse() { val item = makeSpecialItem(TYPE_HEADER_FILES) diff --git a/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterShouldLoadThumbnailTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterShouldLoadThumbnailTest.kt index 26cfa3d2be..51abcbe9b3 100644 --- a/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterShouldLoadThumbnailTest.kt +++ b/app/src/test/java/com/amaze/filemanager/adapters/RecyclerAdapterShouldLoadThumbnailTest.kt @@ -35,7 +35,7 @@ import org.junit.Test * means "no limit"). */ class RecyclerAdapterShouldLoadThumbnailTest { - /** Matches R.array.thumbnailDisplaySizeLimitPreference: [-1, 1, 4, 10, 100] */ + // Matches R.array.thumbnailDisplaySizeLimitPreference: [-1, 1, 4, 10, 100] private val maxSizes = intArrayOf(-1, 1, 4, 10, 100) // ------------------------------------------------------------------ showThumb = false @@ -60,6 +60,10 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } + /** + * Even if the file size is well below the cap, when showThumb=false, no thumbnail should load + * for remote files + */ @Test fun testShowThumbFalse_remoteSmallFile_returnsFalse() { assertFalse( @@ -97,6 +101,10 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } + /** + * Even if showThumb=true and the file size is well below the 1 MB cap, + * FTP mode should still return false + */ @Test fun testFtpMode_showThumbTrue_withCap_smallFile_returnsFalse() { assertFalse( @@ -113,8 +121,6 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } - // ------------------------------------------------------------------ Local modes - /** * Local file-system modes have no size cap – thumbnails are always loaded when showThumb=true. */ @@ -134,6 +140,9 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } + /** + * Root mode also has no size cap – thumbnails should load for any file when showThumb=true. + */ @Test fun testRootMode_returnsTrue() { assertTrue( @@ -147,8 +156,6 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } - // ------------------------------------------------------------------ Remote modes: no cap - /** * When capIndex == 0 ("No limit"), any file size should load a thumbnail for remote modes. */ @@ -167,6 +174,9 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } + /** + * When capIndex == 0 ("No limit"), any file size should load a thumbnail for remote modes + */ @Test fun testSmb_noCap_largeFile_returnsTrue() { assertTrue( @@ -180,8 +190,6 @@ class RecyclerAdapterShouldLoadThumbnailTest { ) } - // ------------------------------------------------------------------ Remote modes: with cap - /** File strictly below the 1 MB cap → thumbnail should load. */ @Test fun testSftp_1MbCap_smallFile_returnsTrue() { diff --git a/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt index fe468cb043..4bdfff939d 100644 --- a/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt +++ b/app/src/test/java/com/amaze/filemanager/adapters/data/LayoutElementParcelableTest.kt @@ -47,7 +47,11 @@ import org.robolectric.annotation.Config sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], shadows = [ShadowMultiDex::class], ) +@Suppress("StringLiteralDuplication") class LayoutElementParcelableTest { + /** + * Set up before test. + */ @Before fun setUp() { // By default Robolectric's MimeTypeMap is empty, we need to populate them @@ -61,6 +65,9 @@ class LayoutElementParcelableTest { .apply() } + /** + * After test clean up. + */ @After fun tearDown() { PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) diff --git a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt index 1ff338394a..ef0b898931 100644 --- a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt +++ b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcherTest.kt @@ -20,8 +20,10 @@ package com.amaze.filemanager.adapters.glide.cloudicon +import android.content.Context import android.graphics.Bitmap import com.amaze.filemanager.adapters.glide.cloudicon.CloudIconDataFetcher.Companion.calculateInSampleSize +import com.bumptech.glide.load.DataSource import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test @@ -34,6 +36,7 @@ import java.io.InputStream * - [CloudIconDataFetcher.Companion.calculateInSampleSize] — pure arithmetic, no Android runtime needed. * - [CloudIconDataFetcher.cancel] — sets the cancelled flag so a subsequent [loadData] returns null. */ +@Suppress("StringLiteralDuplication") class CloudIconDataFetcherTest { // ------------------------------------------------------------------------- // calculateInSampleSize @@ -157,7 +160,7 @@ class CloudIconDataFetcherTest { */ @Test fun testCancel_closesStreamWithoutException() { - val context = mockk(relaxed = true) + val context = mockk(relaxed = true) val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) // Should not throw even when there is no active stream. @@ -206,7 +209,7 @@ class CloudIconDataFetcherTest { */ @Test fun testCleanup_noStream_doesNotThrow() { - val context = mockk(relaxed = true) + val context = mockk(relaxed = true) val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) fetcher.cleanup() // must not throw } @@ -227,7 +230,7 @@ class CloudIconDataFetcherTest { } } - val context = mockk(relaxed = true) + val context = mockk(relaxed = true) val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) val field = CloudIconDataFetcher::class.java.getDeclaredField("inputStream") @@ -239,21 +242,23 @@ class CloudIconDataFetcherTest { assertEquals("cleanup() must close the stream", 1, closed.size) } - // ------------------------------------------------------------------------- - // getDataClass / getDataSource contract - // ------------------------------------------------------------------------- - + /** + * Test [CloudIconDataFetcher.getDataClass] must return Bitmap + */ @Test fun testGetDataClass_returnsBitmapClass() { - val context = mockk(relaxed = true) + val context = mockk(relaxed = true) val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) assertEquals(Bitmap::class.java, fetcher.dataClass) } + /** + * Test [CloudIconDataFetcher.getDataSource] must return REMOTE + */ @Test fun testGetDataSource_returnsRemote() { - val context = mockk(relaxed = true) + val context = mockk(relaxed = true) val fetcher = CloudIconDataFetcher(context, "smb://host/file.jpg", 100, 100) - assertEquals(com.bumptech.glide.load.DataSource.REMOTE, fetcher.dataSource) + assertEquals(DataSource.REMOTE, fetcher.dataSource) } } diff --git a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt index f50aee8f3b..f9cbd0359e 100644 --- a/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt +++ b/app/src/test/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoaderTest.kt @@ -46,7 +46,6 @@ class CloudIconModelLoaderTest { private val context = mockk(relaxed = true) private val loader = CloudIconModelLoader(context) - /** * Only paths that look like cloud storage URLs should return `true` */ diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt index 860939205e..08459b628c 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/UiPrefsFragmentTest.kt @@ -75,6 +75,9 @@ class UiPrefsFragmentTest : AbstractPreferencesFragmentTest("ui } } + /** + * Verify that enabling/disabling show thumbnails also enables/disables the max size option + */ @Test fun testShowThumbnailsCheckbox() { performTest { prefs, preferencesActivity, prefsFragment -> @@ -86,6 +89,9 @@ class UiPrefsFragmentTest : AbstractPreferencesFragmentTest("ui } } + /** + * Verify that the max size options are displayed correctly and have the correct values. + */ @Test fun testShowRemoteThumbnailsMaxSizeOptions() { val presetItems = diff --git a/file_operations/build.gradle b/file_operations/build.gradle index 5400d64458..1f27eb75ab 100644 --- a/file_operations/build.gradle +++ b/file_operations/build.gradle @@ -8,6 +8,12 @@ android { compileSdk libs.versions.compileSdk.get().toInteger() ndkVersion libs.versions.ndk.get() + packagingOptions { + resources { + excludes += ['proguard-project.txt', 'project.properties', 'META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + } + } + defaultConfig { minSdkVersion libs.versions.minSdk.get().toInteger() ndkVersion libs.versions.ndk.get()