Add sort downloads by size feature

I want to try to resolve #6642 .
Please make a review. I hope it helps.

Sort downloads by size

Sort downloads by size
### Checklist

- [X] I have used the search function for [open](https://github.com/AntennaPod/AntennaPod/issues) **and** [closed](https://github.com/AntennaPod/AntennaPod/issues?q=is%3Aissue+is%3Aclosed) issues to see if someone else has already submitted the same feature request.
- [X] I will describe the problem with as much detail as possible.
- [X] This request contains only one single feature, **not** a list of multiple (related) features.

### App version

3.1.0

### Where did you get the app from

Google Play

### Problem you may be having, or feature you want

The Downloads pane supports sorting by date, duration and title, but it would be nice to be able to sort by size of the file, to see large episodes that are taking up space.

### Suggested solution

Add a new sort

### Screenshots / Drawings / Technical details

_No response_

Hey @Julian . I would like to work on this issue!! Is this still open?
Yes, it is still open :)
Great!! I would like to work on it then.

./gradlew :core:testFreeDebugUnitTest --tests "de.danoeh.antennapod.core.util.FeedItemPermutorsTest"

diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java
index ba328adba71..730a3918988 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java
@@ -159,6 +159,7 @@ private void inflateSortMenu(MaterialToolbar toolbar) {
         menu.findItem(R.id.sort_random).setVisible(false);
         menu.findItem(R.id.sort_smart_shuffle).setVisible(false);
         menu.findItem(R.id.keep_sorted).setVisible(false);
+        menu.findItem(R.id.sort_size).setVisible(true);
     }

     @Override
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/MenuItemToSortOrderConverter.java b/app/src/main/java/de/danoeh/antennapod/fragment/MenuItemToSortOrderConverter.java
index 51911b12281..d4150fbdb5f 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/MenuItemToSortOrderConverter.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/MenuItemToSortOrderConverter.java
@@ -32,6 +32,10 @@ public static SortOrder convert(MenuItem item) {
             return SortOrder.SMART_SHUFFLE_OLD_NEW;
         } else if (itemId == R.id.sort_smart_shuffle_desc) {
             return SortOrder.SMART_SHUFFLE_NEW_OLD;
+        } else if (itemId == R.id.sort_size_small_large) {
+            return SortOrder.SIZE_SMALL_LARGE;
+        } else if (itemId == R.id.sort_size_large_small) {
+            return SortOrder.SIZE_LARGE_SMALL;
         }

         return null;
diff --git a/app/src/main/res/menu/sort_menu.xml b/app/src/main/res/menu/sort_menu.xml
index f0a22042b07..96ad4b9f6d7 100644
--- a/app/src/main/res/menu/sort_menu.xml
+++ b/app/src/main/res/menu/sort_menu.xml
@@ -75,6 +75,22 @@
                 android:title="@string/sort_new_old"/>
         </menu>
     </item>
+
+    <item
+        android:id="@+id/sort_size"
+        android:title="@string/size"
+        android:visible="false">
+
+        <menu>
+            <item
+                android:id="@+id/sort_size_small_large"
+                android:title="@string/sort_small_large"/>
+            <item
+                android:id="@+id/sort_size_large_small"
+                android:title="@string/sort_large_small"/>
+        </menu>
+    </item>
+
     <item
         android:id="@+id/keep_sorted"
         android:title="@string/keep_sorted"
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java
index 12377791e22..ec9c9e55e4e 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java
@@ -72,6 +72,12 @@ public static Permutor<FeedItem> getPermutor(@NonNull SortOrder sortOrder) {
             case SMART_SHUFFLE_NEW_OLD:
                 permutor = (queue) -> smartShuffle(queue, false);
                 break;
+            case SIZE_SMALL_LARGE:
+                comparator = (f1, f2) -> Long.compare(size(f1), size(f2));
+                break;
+            case SIZE_LARGE_SMALL:
+                comparator = (f1, f2) -> Long.compare(size(f2), size(f1));
+                break;
         }

         if (comparator != null) {
@@ -97,6 +103,10 @@ private static int duration(@Nullable FeedItem item) {
         return (item != null && item.getMedia() != null) ? item.getMedia().getDuration() : 0;
     }

+    private static long size(@Nullable FeedItem item) {
+        return (item != null && item.getMedia() != null) ? item.getMedia().getSize() : 0;
+    }
+
     @NonNull
     private static String itemLink(@Nullable FeedItem item) {
         return (item != null && item.getLink() != null)
diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java b/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java
index 1f9687b2ee6..08504b44545 100644
--- a/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java
+++ b/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java
@@ -20,6 +20,8 @@ public enum SortOrder {
     DURATION_LONG_SHORT(6, INTRA_FEED),
     EPISODE_FILENAME_A_Z(7, INTRA_FEED),
     EPISODE_FILENAME_Z_A(8, INTRA_FEED),
+    SIZE_SMALL_LARGE(9, INTRA_FEED),
+    SIZE_LARGE_SMALL(10, INTRA_FEED),
     FEED_TITLE_A_Z(101, INTER_FEED),
     FEED_TITLE_Z_A(102, INTER_FEED),
     RANDOM(103, INTER_FEED),
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/mapper/FeedItemSortQuery.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/mapper/FeedItemSortQuery.java
index aae5154d3e9..d4404a08208 100644
--- a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/mapper/FeedItemSortQuery.java
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/mapper/FeedItemSortQuery.java
@@ -25,6 +25,12 @@ public static String generateFrom(SortOrder sortOrder) {
             case DURATION_LONG_SHORT:
                 sortQuery = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_DURATION + " " + "DESC";
                 break;
+            case SIZE_SMALL_LARGE:
+                sortQuery = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_SIZE + " " + "ASC";
+                break;
+            case SIZE_LARGE_SMALL:
+                sortQuery = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_SIZE + " " + "DESC";
+                break;
             default:
                 sortQuery = "";
                 break;
diff --git a/ui/i18n/src/main/res/values/strings.xml b/ui/i18n/src/main/res/values/strings.xml
index 0697a2f5374..73423f4b5cb 100644
--- a/ui/i18n/src/main/res/values/strings.xml
+++ b/ui/i18n/src/main/res/values/strings.xml
@@ -343,6 +343,7 @@
     <string name="feed_title">Podcast title</string>
     <string name="random">Random</string>
     <string name="smart_shuffle">Smart shuffle</string>
+    <string name="size">Size</string>
     <string name="clear_queue_confirmation_msg">Please confirm that you want to clear the queue of ALL of the episodes in it</string>
     <string name="time_left_label">Time left:\u0020</string>

@@ -771,6 +772,8 @@
     <string name="sort_old_new">Old \u2192 New</string>
     <string name="sort_short_long">Short \u2192 Long</string>
     <string name="sort_long_short">Long \u2192 Short</string>
+    <string name="sort_small_large">Small \u2192 Large</string>
+    <string name="sort_large_small">Large \u2192 Small</string>

     <!-- Share episode dialog -->
     <string name="share_playback_position_dialog_label">Include playback position</string>

diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/FeedItemPermutorsTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/FeedItemPermutorsTest.java
index 9c48f8df7eb..be4012cb6cc 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/util/FeedItemPermutorsTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/FeedItemPermutorsTest.java
@@ -112,6 +112,26 @@ public void testPermutorForRule_DURATION_DESC() {
         assertTrue(checkIdOrder(itemList, 3, 2, 1)); // after sorting
     }

+    @Test
+    public void testPermutorForRule_size_asc() {
+        Permutor<FeedItem> permutor = FeedItemPermutors.getPermutor(SortOrder.SIZE_SMALL_LARGE);
+
+        List<FeedItem> itemList = getTestList();
+        assertTrue(checkIdOrder(itemList, 1, 3, 2)); // before sorting
+        permutor.reorder(itemList);
+        assertTrue(checkIdOrder(itemList, 1, 2, 3)); // after sorting
+    }
+
+    @Test
+    public void testPermutorForRule_size_desc() {
+        Permutor<FeedItem> permutor = FeedItemPermutors.getPermutor(SortOrder.SIZE_LARGE_SMALL);
+
+        List<FeedItem> itemList = getTestList();
+        assertTrue(checkIdOrder(itemList, 1, 3, 2)); // before sorting
+        permutor.reorder(itemList);
+        assertTrue(checkIdOrder(itemList, 3, 2, 1)); // after sorting
+    }
+
     @Test
     public void testPermutorForRule_DURATION_DESC_NullMedia() {
         Permutor<FeedItem> permutor = FeedItemPermutors.getPermutor(SortOrder.DURATION_LONG_SHORT);
@@ -166,21 +186,21 @@ private List<FeedItem> getTestList() {
         calendar.set(2019, 0, 1);  // January 1st
         Feed feed1 = new Feed(null, null, "Feed title 1");
         FeedItem feedItem1 = new FeedItem(1, "Title 1", null, null, calendar.getTime(), 0, feed1);
-        FeedMedia feedMedia1 = new FeedMedia(0, feedItem1, 1000, 0, 0, null, null, null, true, null, 0, 0);
+        FeedMedia feedMedia1 = new FeedMedia(0, feedItem1, 1000, 0, 100, null, null, null, true, null, 0, 0);
         feedItem1.setMedia(feedMedia1);
         itemList.add(feedItem1);

         calendar.set(2019, 2, 1);  // March 1st
         Feed feed2 = new Feed(null, null, "Feed title 3");
         FeedItem feedItem2 = new FeedItem(3, "Title 3", null, null, calendar.getTime(), 0, feed2);
-        FeedMedia feedMedia2 = new FeedMedia(0, feedItem2, 3000, 0, 0, null, null, null, true, null, 0, 0);
+        FeedMedia feedMedia2 = new FeedMedia(0, feedItem2, 3000, 0, 300, null, null, null, true, null, 0, 0);
         feedItem2.setMedia(feedMedia2);
         itemList.add(feedItem2);

         calendar.set(2019, 1, 1);  // February 1st
         Feed feed3 = new Feed(null, null, "Feed title 2");
         FeedItem feedItem3 = new FeedItem(2, "Title 2", null, null, calendar.getTime(), 0, feed3);
-        FeedMedia feedMedia3 = new FeedMedia(0, feedItem3, 2000, 0, 0, null, null, null, true, null, 0, 0);
+        FeedMedia feedMedia3 = new FeedMedia(0, feedItem3, 2000, 0, 200, null, null, null, true, null, 0, 0);
         feedItem3.setMedia(feedMedia3);
         itemList.add(feedItem3);


Add volume boost

This PR changes "Volume reduction" setting into "Volume adaptation" setting, adding three extra options for volume boost in addition to existing light and heavy reduction options-

This fixes #5286

Option to allow boosting audio volume over 100%
# Checklist

- [x ] I have used the search function to see if someone else has already submitted the same feature request.
- [ x] I will only create one feature request per issue.
- [x ] I will describe the problem with as much detail as possible.

# System info

**App version**: 2.2.1 (latest)

**App source**: Google Play

# Feature description

**Problem you may be having, or feature you want**:
Some of the episodes I have are using a lower audio volume, and even if I'm at 100% of the volume I cannot hear correctly.

**Suggested solution**:
Maybe we could implement an option that allow to boost audio volume over 100% ?
For safety reasons, it could be an option that is disabled by default and is available to be enabled in the advanced settings tab.

Hi and thank you for writing this issue!

It looks like you did not follow our issue template. In case you accidentally deleted the template, you can find the version for bug reports [here](https://raw.githubusercontent.com/AntennaPod/AntennaPod/develop/.github/ISSUE_TEMPLATE/bug_report.md) and the version for feature requests [here](https://raw.githubusercontent.com/AntennaPod/AntennaPod/develop/.github/ISSUE_TEMPLATE/feature_request.md). Please copy the matching template and then edit your post to follow the template. Please also make sure to use the [search function](https://github.com/AntennaPod/AntennaPod/issues?q=is%3Aissue) to look for open **and** closed issues about the same topic.

In order to properly deal with the issue, we need the information requested in the template. Please note that dealing with duplicate issues and asking users to follow the issue template takes away a lot of time from the developers. That time can then not be spent to actually implement features.
Related: #2187
Interested in seeing this feature happen.
This is the reason for me leaving this app that actually I like a lot.

When driving a car, the traffic instructions are sometimes so much louder than a badly recorded hardly audible podcast that it blows my ears. For health reasons, I have to switch now. (I am not exaggerating - it is literally painful.)

Thanks for the great work so far!

./gradlew :core:testFreeDebugUnitTest --tests "de.danoeh.antennapod.core.feed.VolumeAdaptionSettingTest"

diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
index ae9e003d504..0e4c883cfe1 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
@@ -156,7 +156,7 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
                         setupAutoDownloadPreference();
                         setupKeepUpdatedPreference();
                         setupAutoDeletePreference();
-                        setupVolumeReductionPreferences();
+                        setupVolumeAdaptationPreferences();
                         setupNewEpisodesAction();
                         setupAuthentificationPreference();
                         setupEpisodeFilterPreference();
@@ -166,7 +166,7 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
                         setupTags();

                         updateAutoDeleteSummary();
-                        updateVolumeReductionValue();
+                        updateVolumeAdaptationValue();
                         updateAutoDownloadEnabled();
                         updateNewEpisodesAction();

@@ -317,9 +317,9 @@ private void updateAutoDeleteSummary() {
             }
         }

-        private void setupVolumeReductionPreferences() {
-            ListPreference volumeReductionPreference = findPreference("volumeReduction");
-            volumeReductionPreference.setOnPreferenceChangeListener((preference, newValue) -> {
+        private void setupVolumeAdaptationPreferences() {
+            ListPreference volumeAdaptationPreference = findPreference("volumeReduction");
+            volumeAdaptationPreference.setOnPreferenceChangeListener((preference, newValue) -> {
                 switch ((String) newValue) {
                     case "off":
                         feedPreferences.setVolumeAdaptionSetting(VolumeAdaptionSetting.OFF);
@@ -330,28 +330,46 @@ private void setupVolumeReductionPreferences() {
                     case "heavy":
                         feedPreferences.setVolumeAdaptionSetting(VolumeAdaptionSetting.HEAVY_REDUCTION);
                         break;
+                    case "light_boost":
+                        feedPreferences.setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_BOOST);
+                        break;
+                    case "medium_boost":
+                        feedPreferences.setVolumeAdaptionSetting(VolumeAdaptionSetting.MEDIUM_BOOST);
+                        break;
+                    case "heavy_boost":
+                        feedPreferences.setVolumeAdaptionSetting(VolumeAdaptionSetting.HEAVY_BOOST);
+                        break;
                     default:
                 }
                 DBWriter.setFeedPreferences(feedPreferences);
-                updateVolumeReductionValue();
+                updateVolumeAdaptationValue();
                 EventBus.getDefault().post(
                         new VolumeAdaptionChangedEvent(feedPreferences.getVolumeAdaptionSetting(), feed.getId()));
                 return false;
             });
         }

-        private void updateVolumeReductionValue() {
-            ListPreference volumeReductionPreference = findPreference("volumeReduction");
+        private void updateVolumeAdaptationValue() {
+            ListPreference volumeAdaptationPreference = findPreference("volumeReduction");

             switch (feedPreferences.getVolumeAdaptionSetting()) {
                 case OFF:
-                    volumeReductionPreference.setValue("off");
+                    volumeAdaptationPreference.setValue("off");
                     break;
                 case LIGHT_REDUCTION:
-                    volumeReductionPreference.setValue("light");
+                    volumeAdaptationPreference.setValue("light");
                     break;
                 case HEAVY_REDUCTION:
-                    volumeReductionPreference.setValue("heavy");
+                    volumeAdaptationPreference.setValue("heavy");
+                    break;
+                case LIGHT_BOOST:
+                    volumeAdaptationPreference.setValue("light_boost");
+                    break;
+                case MEDIUM_BOOST:
+                    volumeAdaptationPreference.setValue("medium_boost");
+                    break;
+                case HEAVY_BOOST:
+                    volumeAdaptationPreference.setValue("heavy_boost");
                     break;
             }
         }
diff --git a/app/src/main/res/xml/feed_settings.xml b/app/src/main/res/xml/feed_settings.xml
index 619ab62966e..fb9e2e42544 100644
--- a/app/src/main/res/xml/feed_settings.xml
+++ b/app/src/main/res/xml/feed_settings.xml
@@ -50,12 +50,12 @@

     <de.danoeh.antennapod.preferences.MaterialListPreference
         android:defaultValue="off"
-        android:entries="@array/spnVolumeReductionItems"
-        android:entryValues="@array/spnVolumeReductionValues"
+        android:entries="@array/spnVolumeAdaptationItems"
+        android:entryValues="@array/spnVolumeAdaptationValues"
         android:icon="@drawable/ic_volume_adaption"
         android:key="volumeReduction"
-        android:summary="@string/feed_volume_reduction_summary"
-        android:title="@string/feed_volume_reduction" />
+        android:summary="@string/feed_volume_adaptation_summary"
+        android:title="@string/feed_volume_adapdation" />

     <de.danoeh.antennapod.preferences.MaterialListPreference
         android:entries="@array/feedNewEpisodesActionItems"
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
index e0c5da00bb9..c275444d9a3 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
@@ -1,6 +1,7 @@
 package de.danoeh.antennapod.core.service.playback;

 import android.content.Context;
+import android.media.audiofx.LoudnessEnhancer;
 import android.net.Uri;
 import android.text.TextUtils;
 import android.util.Log;
@@ -68,6 +69,8 @@ public class ExoPlayerWrapper {
     private PlaybackParameters playbackParameters;
     private DefaultTrackSelector trackSelector;

+    private LoudnessEnhancer loudnessEnhancer;
+
     ExoPlayerWrapper(Context context) {
         this.context = context;
         createPlayer();
@@ -133,7 +136,14 @@ public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition,
                     audioSeekCompleteListener.run();
                 }
             }
+
+            @Override
+            public void onAudioSessionIdChanged(int audioSessionId) {
+                initLoudnessEnhancer(audioSessionId);
+            }
         });
+
+        initLoudnessEnhancer(exoPlayer.getAudioSessionId());
     }

     public int getCurrentPosition() {
@@ -235,7 +245,14 @@ public void setPlaybackParams(float speed, boolean skipSilence) {
     }

     public void setVolume(float v, float v1) {
-        exoPlayer.setVolume(v);
+        if (v > 1) {
+            exoPlayer.setVolume(1f);
+            loudnessEnhancer.setEnabled(true);
+            loudnessEnhancer.setTargetGain((int) (1000 * (v - 1)));
+        } else {
+            exoPlayer.setVolume(v);
+            loudnessEnhancer.setEnabled(false);
+        }
     }

     public void start() {
@@ -335,4 +352,16 @@ int getVideoHeight() {
     void setOnBufferingUpdateListener(Consumer<Integer> bufferingUpdateListener) {
         this.bufferingUpdateListener = bufferingUpdateListener;
     }
+
+    private void initLoudnessEnhancer(int audioStreamId) {
+        LoudnessEnhancer newEnhancer = new LoudnessEnhancer(audioStreamId);
+        LoudnessEnhancer oldEnhancer = this.loudnessEnhancer;
+        if (oldEnhancer != null) {
+            newEnhancer.setEnabled(oldEnhancer.getEnabled());
+            newEnhancer.setTargetGain((int) oldEnhancer.getTargetGain());
+            oldEnhancer.release();
+        }
+
+        this.loudnessEnhancer = newEnhancer;
+    }
 }
diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml
index 7749c6f3c81..50afe630b7a 100644
--- a/core/src/main/res/values/arrays.xml
+++ b/core/src/main/res/values/arrays.xml
@@ -13,16 +13,22 @@
         <item>never</item>
     </string-array>

-    <string-array name="spnVolumeReductionItems">
-        <item>@string/feed_volume_reduction_off</item>
-        <item>@string/feed_volume_reduction_light</item>
+    <string-array name="spnVolumeAdaptationItems">
         <item>@string/feed_volume_reduction_heavy</item>
+        <item>@string/feed_volume_reduction_light</item>
+        <item>@string/feed_volume_reduction_off</item>
+        <item>@string/feed_volume_boost_light</item>
+        <item>@string/feed_volume_boost_medium</item>
+        <item>@string/feed_volume_boost_heavy</item>
     </string-array>

-    <string-array name="spnVolumeReductionValues">
-        <item>off</item>
-        <item>light</item>
+    <string-array name="spnVolumeAdaptationValues">
         <item>heavy</item>
+        <item>light</item>
+        <item>off</item>
+        <item>light_boost</item>
+        <item>medium_boost</item>
+        <item>heavy_boost</item>
     </string-array>

     <string-array name="feed_refresh_interval_entries">
diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java b/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java
index 67e30e5c817..e71c5ad3634 100644
--- a/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java
+++ b/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java
@@ -3,7 +3,10 @@
 public enum VolumeAdaptionSetting {
     OFF(0, 1.0f),
     LIGHT_REDUCTION(1, 0.5f),
-    HEAVY_REDUCTION(2, 0.2f);
+    HEAVY_REDUCTION(2, 0.2f),
+    LIGHT_BOOST(3, 1.5f),
+    MEDIUM_BOOST(4, 2f),
+    HEAVY_BOOST(5, 2.5f);

     private final int value;
     private float adaptionFactor;
diff --git a/ui/i18n/src/main/res/values/strings.xml b/ui/i18n/src/main/res/values/strings.xml
index 6bdb836dd2b..baed14c5538 100644
--- a/ui/i18n/src/main/res/values/strings.xml
+++ b/ui/i18n/src/main/res/values/strings.xml
@@ -124,11 +124,14 @@
     <string name="retry_label">Retry</string>
     <string name="auto_download_label">Include in auto downloads</string>
     <string name="auto_delete_label">Auto delete episode</string>
-    <string name="feed_volume_reduction">Volume reduction</string>
-    <string name="feed_volume_reduction_summary">Turn down volume for episodes of this podcast: %1$s</string>
-    <string name="feed_volume_reduction_off">Off</string>
-    <string name="feed_volume_reduction_light">Light</string>
-    <string name="feed_volume_reduction_heavy">Heavy</string>
+    <string name="feed_volume_adapdation">Volume adaptation</string>
+    <string name="feed_volume_adaptation_summary">Turn volume for episodes of this podcast up or down: %1$s</string>
+    <string name="feed_volume_reduction_off">No adaption</string>
+    <string name="feed_volume_reduction_light">Light reduction</string>
+    <string name="feed_volume_reduction_heavy">Heavy reduction</string>
+    <string name="feed_volume_boost_light">Light boost</string>
+    <string name="feed_volume_boost_medium">Medium boost</string>
+    <string name="feed_volume_boost_heavy">Heavy boost</string>
     <string name="feed_auto_download_always">Always</string>
     <string name="feed_auto_download_never">Never</string>
     <string name="feed_new_episodes_action_add_to_inbox">Add to inbox</string>

diff --git a/app/src/androidTest/java/de/test/antennapod/ui/FeedSettingsTest.java b/app/src/androidTest/java/de/test/antennapod/ui/FeedSettingsTest.java
index 861c62f1bff..187c9e16dac 100644
--- a/app/src/androidTest/java/de/test/antennapod/ui/FeedSettingsTest.java
+++ b/app/src/androidTest/java/de/test/antennapod/ui/FeedSettingsTest.java
@@ -73,7 +73,7 @@ public void testClickFeedSettings() {
         clickPreference(R.string.auto_delete_label);
         onView(withText(R.string.cancel_label)).perform(click());

-        clickPreference(R.string.feed_volume_reduction);
+        clickPreference(R.string.feed_volume_adapdation);
         onView(withText(R.string.cancel_label)).perform(click());
     }
 }
diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/VolumeAdaptionSettingTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/VolumeAdaptionSettingTest.java
index 4241707bc5d..30767bdc884 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/feed/VolumeAdaptionSettingTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/VolumeAdaptionSettingTest.java
@@ -31,11 +31,35 @@ public void mapHeavyReductionToInteger() {
         assertThat(setting.toInteger(), is(equalTo(2)));
     }

+    @Test
+    public void mapLightBoostToInteger() {
+        VolumeAdaptionSetting setting = VolumeAdaptionSetting.LIGHT_BOOST;
+
+        assertThat(setting.toInteger(), is(equalTo(3)));
+    }
+
+    @Test
+    public void mapMediumBoostToInteger() {
+        VolumeAdaptionSetting setting = VolumeAdaptionSetting.MEDIUM_BOOST;
+
+        assertThat(setting.toInteger(), is(equalTo(4)));
+    }
+
+    @Test
+    public void mapHeavyBoostToInteger() {
+        VolumeAdaptionSetting setting = VolumeAdaptionSetting.HEAVY_BOOST;
+
+        assertThat(setting.toInteger(), is(equalTo(5)));
+    }
+
     @Test
     public void mapIntegerToVolumeAdaptionSetting() {
         assertThat(VolumeAdaptionSetting.fromInteger(0), is(equalTo(VolumeAdaptionSetting.OFF)));
         assertThat(VolumeAdaptionSetting.fromInteger(1), is(equalTo(VolumeAdaptionSetting.LIGHT_REDUCTION)));
         assertThat(VolumeAdaptionSetting.fromInteger(2), is(equalTo(VolumeAdaptionSetting.HEAVY_REDUCTION)));
+        assertThat(VolumeAdaptionSetting.fromInteger(3), is(equalTo(VolumeAdaptionSetting.LIGHT_BOOST)));
+        assertThat(VolumeAdaptionSetting.fromInteger(4), is(equalTo(VolumeAdaptionSetting.MEDIUM_BOOST)));
+        assertThat(VolumeAdaptionSetting.fromInteger(5), is(equalTo(VolumeAdaptionSetting.HEAVY_BOOST)));
     }

     @Test(expected =  IllegalArgumentException.class)
@@ -45,7 +69,7 @@ public void cannotMapNegativeValues() {

     @Test(expected =  IllegalArgumentException.class)
     public void cannotMapValuesOutOfRange() {
-        VolumeAdaptionSetting.fromInteger(3);
+        VolumeAdaptionSetting.fromInteger(6);
     }

     @Test
@@ -62,4 +86,31 @@ public void lightReductionYieldsHigherValueThanHeavyReduction() {

         assertTrue("Light reduction must have higher factor than heavy reduction", lightReductionFactor > heavyReductionFactor);
     }
-}
\ No newline at end of file
+
+    @Test
+    public void lightBoostYieldsHigherValueThanLightReduction() {
+        float lightReductionFactor = VolumeAdaptionSetting.LIGHT_REDUCTION.getAdaptionFactor();
+
+        float lightBoostFactor = VolumeAdaptionSetting.LIGHT_BOOST.getAdaptionFactor();
+
+        assertTrue("Light boost must have higher factor than light reduction", lightBoostFactor > lightReductionFactor);
+    }
+
+    @Test
+    public void mediumBoostYieldsHigherValueThanLightBoost() {
+        float lightBoostFactor = VolumeAdaptionSetting.LIGHT_BOOST.getAdaptionFactor();
+
+        float mediumBoostFactor = VolumeAdaptionSetting.MEDIUM_BOOST.getAdaptionFactor();
+
+        assertTrue("Medium boost must have higher factor than light boost", mediumBoostFactor > lightBoostFactor);
+    }
+
+    @Test
+    public void heavyBoostYieldsHigherValueThanMediumBoost() {
+        float mediumBoostFactor = VolumeAdaptionSetting.MEDIUM_BOOST.getAdaptionFactor();
+
+        float heavyBoostFactor = VolumeAdaptionSetting.HEAVY_BOOST.getAdaptionFactor();
+
+        assertTrue("Heavy boost must have higher factor than medium boost", heavyBoostFactor > mediumBoostFactor);
+    }
+}


Test/2819 add campaigns api tests

**Description (required)**

Fixes #2819

What changes did you make and why?

This PR implements the requested unit tests for the `OkHttpJsonApiClient:getCampaigns` method to ensure the API layer is working correctly.

The changes include:
1.  **Model Fixes:** Updated `CampaignConfig` and `CampaignResponseDTO` by changing their fields from immutable (`val`) to mutable (`var`). This is essential to allow the Gson library to correctly deserialize the JSON data into these Kotlin data classes during the test run and at application runtime.
2.  **Test Resources:** Added two new JSON files (`campaigns_response_with_data.json` and `campaigns_response_empty.json`) to the test resources directory to provide mock responses for the API calls.
3.  **Unit Tests:** Implemented the two required test cases in `OkHttpJsonApiClientTests.kt` to cover both scenarios: a successful response with campaign data and a successful response with an empty campaign list.

**Tests performed (required)**

Tested `ProdDebug` on `Redmi note 13 pro 5g` with API level `35`.

Add unit test for fetching campaigns

Add unit test for fetching campaigns
**Summary:**

Now that we have #2793 in place we can easily test campaigns. At least we should have unit tests to verify the API layer (using mock web server). Specifically we need to test `OkHttpJsonApiClient:getCampaigns` for 2 scenarios.

- when the API returns at least 1 campaign(active or otherwise)
- when the API returns no campaigns.

Can i  work on this @maskaravivek
Sure take this up. @JobGetabu
Hi @maskaravivek! I would like to work on this issue if no one else is looking into it.
@SaumiaSinghal  Yes, sure. Assigned to you.
Hi @maskaravivek! Thanks for assigning the issue. I'm running into an error on running the tests that I wrote, I have pointed out the error on my draft PR [here](https://github.com/commons-app/apps-android-commons/pull/3862/files#r448880632). Can you please check once whenever you have time? Thanks.
@neslihanturan Could I work on this one as part of Hacktoberfest?
Hi, if no one else is looking into this issue I'd like to try to resolve it.

./gradlew :app:testBetaDebugUnitTest --tests "fr.free.nrw.commons.OkHttpJsonApiClientTests"

diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
index 6bf0bc0ed6..9f94e85927 100644
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
@@ -7,8 +7,8 @@ import com.google.gson.annotations.SerializedName
  */
 class CampaignConfig {
     @SerializedName("showOnlyLiveCampaigns")
-    private val showOnlyLiveCampaigns = false
+    var showOnlyLiveCampaigns = false

     @SerializedName("sortBy")
-    private val sortBy: String? = null
-}
+    var sortBy: String? = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
index 767732eb78..1656109e74 100644
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
@@ -8,8 +8,8 @@ import fr.free.nrw.commons.campaigns.models.Campaign
  */
 class CampaignResponseDTO {
     @SerializedName("config")
-    val campaignConfig: CampaignConfig? = null
+    var campaignConfig: CampaignConfig? = null

     @SerializedName("campaigns")
-    val campaigns: List<Campaign>? = null
-}
+    var campaigns: List<Campaign>? = null
+}
\ No newline at end of file

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt
index 9e78915603..1a9d1500b5 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt
@@ -3,6 +3,10 @@ package fr.free.nrw.commons
 import com.google.gson.Gson
 import com.nhaarman.mockitokotlin2.any
 import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.times
+import fr.free.nrw.commons.campaigns.CampaignResponseDTO
+import fr.free.nrw.commons.campaigns.CampaignConfig
+import fr.free.nrw.commons.campaigns.models.Campaign
 import fr.free.nrw.commons.explore.depictions.DepictsClient
 import fr.free.nrw.commons.location.LatLng
 import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
@@ -10,13 +14,22 @@ import fr.free.nrw.commons.nearby.model.NearbyQueryParams
 import okhttp3.Call
 import okhttp3.HttpUrl
 import okhttp3.OkHttpClient
+import okhttp3.Request
 import okhttp3.Response
+import okhttp3.ResponseBody
+import okhttp3.mockwebserver.MockResponse
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Test
 import org.mockito.Mock
 import org.mockito.Mockito
-import org.mockito.Mockito.times
+import org.mockito.Mockito.eq
 import org.mockito.MockitoAnnotations
+import java.io.BufferedReader
+import java.io.InputStreamReader
 import java.lang.Exception

 class OkHttpJsonApiClientTests {
@@ -45,34 +58,43 @@ class OkHttpJsonApiClientTests {
     @Mock
     lateinit var response: Response

+    @Mock
+    lateinit var responseBody: ResponseBody
+
+    private lateinit var mockWebServer: TestWebServer
+
     @Before
     fun setUp() {
         MockitoAnnotations.openMocks(this)
-        okHttpJsonApiClient =
-            OkHttpJsonApiClient(
-                okhttpClient,
-                depictsClient,
-                wikiMediaToolforgeUrl,
-                sparqlQueryUrl,
-                campaignsUrl,
-                gson,
-            )
+        mockWebServer = TestWebServer()
+        mockWebServer.setUp()
+        okHttpJsonApiClient = OkHttpJsonApiClient(
+            okhttpClient,
+            depictsClient,
+            wikiMediaToolforgeUrl,
+            sparqlQueryUrl,
+            mockWebServer.getUrl(), //use the mock server for the campaignsUrl
+            gson
+        )
         Mockito.`when`(okhttpClient.newCall(any())).thenReturn(call)
         Mockito.`when`(call.execute()).thenReturn(response)
+        Mockito.`when`(response.isSuccessful).thenReturn(false)
+        Mockito.`when`(response.message).thenReturn("test")
+        Mockito.`when`(response.body).thenReturn(responseBody)
+        Mockito.`when`(responseBody.string()).thenReturn("{\"error\": \"test\"}")
     }

     @Test
     fun testGetNearbyPlacesCustomQuery() {
-        Mockito.`when`(response.message).thenReturn("test")
         try {
             okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, "test")
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         try {
             okHttpJsonApiClient.getNearbyPlaces(NearbyQueryParams.Rectangular(latLng, latLng), "test", true, "test")
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         verify(okhttpClient, times(2)).newCall(any())
         verify(call, times(2)).execute()
@@ -80,11 +102,10 @@ class OkHttpJsonApiClientTests {

     @Test
     fun testGetNearbyPlaces() {
-        Mockito.`when`(response.message).thenReturn("test")
         try {
             okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, null)
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         try {
             okHttpJsonApiClient.getNearbyPlaces(
@@ -93,9 +114,8 @@ class OkHttpJsonApiClientTests {
                 true,
                 null
             )
-
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         try {
             okHttpJsonApiClient.getNearbyPlaces(
@@ -105,7 +125,7 @@ class OkHttpJsonApiClientTests {
                 null
             )
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         verify(okhttpClient, times(3)).newCall(any())
         verify(call, times(3)).execute()
@@ -113,18 +133,121 @@ class OkHttpJsonApiClientTests {

     @Test
     fun testGetNearbyItemCount() {
-        Mockito.`when`(response.message).thenReturn("test")
         try {
             okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Radial(latLng, 10f))
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         try {
             okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Rectangular(latLng, latLng))
         } catch (e: Exception) {
-            assert(e.message.equals("test"))
+            assertEquals("test", e.message)
         }
         verify(okhttpClient, times(2)).newCall(any())
         verify(call, times(2)).execute()
     }
-}
+
+    @Test
+    fun testGetCampaignsWithData() {
+        //loads the json response from resources
+        val jsonResponse = loadJsonFromResource("campaigns_response_with_data.json")
+
+        //mocks the succesfull response chain
+        Mockito.`when`(response.isSuccessful).thenReturn(true)
+        Mockito.`when`(response.message).thenReturn("OK")
+        Mockito.`when`(response.body).thenReturn(responseBody)
+        Mockito.`when`(responseBody.string()).thenReturn(jsonResponse)
+
+        val campaignResponse = CampaignResponseDTO().apply {
+            campaignConfig = CampaignConfig().apply {
+                showOnlyLiveCampaigns = false
+                sortBy = "startDate"
+            }
+            campaigns = listOf(
+                Campaign().apply {
+                    title = "Wiki Loves Monuments"
+                    isWLMCampaign = true
+                },
+                Campaign().apply {
+                    title = "Wiki Loves Nature"
+                    isWLMCampaign = false
+                }
+            )
+        }
+
+        //any() for the string argument and eq() for the class argument.
+        Mockito.`when`(
+            gson.fromJson(
+                any<String>(),
+                eq(CampaignResponseDTO::class.java)
+            )
+        ).thenReturn(campaignResponse)
+
+        //call the getCampaigns
+        val result: CampaignResponseDTO? = okHttpJsonApiClient.getCampaigns().blockingGet()
+
+        //verify the  results
+        assertNotNull(result)
+        assertNotNull(result?.campaigns)
+        assertEquals(2, result?.campaigns!!.size)
+        assertEquals("Wiki Loves Monuments", result.campaigns!![0].title)
+        assertTrue(result.campaigns!![0].isWLMCampaign)
+        assertEquals("Wiki Loves Nature", result.campaigns!![1].title)
+        assertEquals(false, result.campaigns!![1].isWLMCampaign)
+        assertNotNull(result.campaignConfig)
+        assertFalse(result.campaignConfig!!.showOnlyLiveCampaigns)
+        assertEquals("startDate", result.campaignConfig!!.sortBy)
+    }
+
+    @Test
+    fun testGetCampaignsEmpty() {
+        //loads the empty json response
+        val jsonResponse = loadJsonFromResource("campaigns_response_empty.json")
+
+        //mocks the successful response chain
+        Mockito.`when`(response.isSuccessful).thenReturn(true)
+        Mockito.`when`(response.message).thenReturn("OK")
+        Mockito.`when`(response.body).thenReturn(responseBody)
+        Mockito.`when`(responseBody.string()).thenReturn(jsonResponse)
+
+        val campaignResponse = CampaignResponseDTO().apply {
+            campaignConfig = CampaignConfig().apply {
+                showOnlyLiveCampaigns = false
+                sortBy = "startDate"
+            }
+            campaigns = emptyList()
+        }
+
+        //use any() for the string argument and eq() for the class argument.
+        Mockito.`when`(
+            gson.fromJson(
+                any<String>(),
+                eq(CampaignResponseDTO::class.java)
+            )
+        ).thenReturn(campaignResponse)
+
+        //calls getCampaigns
+        val result: CampaignResponseDTO? = okHttpJsonApiClient.getCampaigns().blockingGet()
+
+        //verify the results
+        assertNotNull(result)
+        assertNotNull(result?.campaigns)
+        assertTrue(result?.campaigns!!.isEmpty())
+        assertNotNull(result.campaignConfig)
+        assertFalse(result.campaignConfig!!.showOnlyLiveCampaigns)
+        assertEquals("startDate", result.campaignConfig!!.sortBy)
+    }
+
+    fun loadJsonFromResource(fileName: String): String {
+        val resourcePath = "raw/$fileName"
+        //uses the classloader to find the resource in the test environment
+        val inputStream = javaClass.classLoader?.getResourceAsStream(resourcePath)
+
+        if (inputStream != null) {
+            //reads the entire stream content
+            return BufferedReader(InputStreamReader(inputStream)).use { it.readText() }
+        }
+        //throws an exception with the correct expected path
+        throw IllegalArgumentException("Resource $fileName not found. Please ensure the file is located in app/src/test/resources/raw/")
+    }
+}
\ No newline at end of file
diff --git a/app/src/test/resources/raw/campaigns_response_empty.json b/app/src/test/resources/raw/campaigns_response_empty.json
new file mode 100644
index 0000000000..61903e24c3
--- /dev/null
+++ b/app/src/test/resources/raw/campaigns_response_empty.json
@@ -0,0 +1,7 @@
+{
+  "config": {
+    "showOnlyLiveCampaigns": false,
+    "sortBy": "startDate"
+  },
+  "campaigns": []
+}
\ No newline at end of file
diff --git a/app/src/test/resources/raw/campaigns_response_with_data.json b/app/src/test/resources/raw/campaigns_response_with_data.json
new file mode 100644
index 0000000000..dab818e2ae
--- /dev/null
+++ b/app/src/test/resources/raw/campaigns_response_with_data.json
@@ -0,0 +1,24 @@
+{
+  "config": {
+    "showOnlyLiveCampaigns": false,
+    "sortBy": "startDate"
+  },
+  "campaigns": [
+    {
+      "title": "Wiki Loves Monuments",
+      "description": "A campaign to photograph monuments",
+      "startDate": "2025-09-01",
+      "endDate": "2025-09-30",
+      "link": "https://commons.wikimedia.org/wiki/Campaign:Wiki_Loves_Monuments",
+      "isWLMCampaign": true
+    },
+    {
+      "title": "Wiki Loves Nature",
+      "description": "A campaign to photograph nature",
+      "startDate": "2025-06-01",
+      "endDate": "2025-06-30",
+      "link": "https://commons.wikimedia.org/wiki/Campaign:Wiki_Loves_Nature",
+      "isWLMCampaign": false
+    }
+  ]
+}
\ No newline at end of file


Enhance filtering criteria for category search

Fixes #4901

What changes did you make and why?

This PR is an extension of #4902 PR. refer to [comment](https://github.com/commons-app/apps-android-commons/issues/4901#issuecomment-2592110652). Conflicting changes were made while this PR was open making changes introduced outdated. Also, some of the checks were redundant so that's why started fresh with this PR.

Now, spam filtering function filter these strings ->

- that contains `needing` or `taken on`
- that contains dates in format `dd mm yyyy or yy`  and `mm/yyyy or yy`  (in any order possible)  with separators `/` `.` `-` in between.
- that contains years that doesn't come in 20XX range.

- [X] Wrote tests.

**Example input and output (true if filtered) ->**
```

Media_needing_categories_as_of_16_June_2017 -> true
Photo_taken on_23_March_2009 -> true
Photo_taken_12/10/2023 -> true
Event_on_25-12-19 -> true
Image_in_22/04/17 -> true
Video_in_2023-05 -> true
Pic_taken_20-12 -> true
Article_in_2024 -> false
Photo_in_1998 -> true
No_date_category -> false
17/04/2021 -> true
Captured_in_1980 -> true
File_uploaded_in_18-05 -> true
Shot_2019/10/15 -> true
Just_some_random_text -> false
Updated_in_21/07 -> true

```

[Bug]: Category "Amavenita (ship, 2014)" not found
### Summary

I serached for a category that exists, but it doesn't show up.

### Steps to reproduce

1. When uploading an image, search for category: Amavenita

### Expected behaviour

[Category:Amavenita (ship, 2014)](https://commons.wikimedia.org/wiki/Category:Amavenita_(ship,_2014)) should be suggested.

### Actual behaviour

"No Categories found"

### Device name

Samsung Galaxy S10

### Android version

Android 12

### Commons app version

3.1.1~1c9267ca0

### Device logs

_No response_

### Screen-shots

![Screenshot_20220318-180530_Commons](https://user-images.githubusercontent.com/5566159/159050916-23c318f1-f0bb-4c53-b8b9-01a1b24a1e4f.jpg)

### Would you like to work on the issue?

_No response_

@Ainali isn't this the expected behaviour? Because, Amavenita (ship, 2014) is a non-hidden category and I think we are only supposed to show the hidden category in search bar. But I could be wrong.
Oh, I didn't realize. Then this is a duplicate with #4192 (and a use case for why it is needed, this is how all ships are categorized and the way for a human to identify them).
> "we are only supposed to show the hidden category in search bar"

@devarsh-mavani-19 I would consider that a bug. If a category is not hidden, then the upload wizard's category search step is **supposed to** show it.
By the way, when I search for "rabbits", the first result is https://commons.wikimedia.org/wiki/Category:Rabbits which is not a hidden category:

![adb](https://user-images.githubusercontent.com/99590/159115296-dff3836c-346d-4088-b66f-00e2c4f8d59e.png)

> By the way, when I search for "rabbits", the first result is https://commons.wikimedia.org/wiki/Category:Rabbits which is not a hidden category:
>
> ![adb](https://user-images.githubusercontent.com/99590/159115296-dff3836c-346d-4088-b66f-00e2c4f8d59e.png)

Yes that is how it should work if it is non-hidden, then it should be displayed. However, in case of Ainali it was not displayed because category name contained a year which was older than previous year  (Amavenita (ship, **2014**)). so according to this issue #750 it should be filtered out.

so just to summarize currently 2 conditions must met 1. it should be non-hidden and 2nd either should not have any year in name or the year should be current or previous year.
> However, in case of Ainali it was not displayed because category name contained a year which was older than previous year (Amavenita (ship, **2014**)). so according to this issue #750 it should be filtered out.

Then I would still consider that issue to have been too poorly defined, because the app shouldn't filter out things where the year is part of the identifier of that thing, in this case, the ship that got the name in 2014. (Also, the app should probably not filter out by only older than current year, since it's common to upload files a day or two after they are taken which means that if I upload an image on January 1, I expect to see the category for the year before. Safer would be to filter categories where the year is less than current year minus 2.)
I'm presuming the issue #750 was understood properly but the fix for it was a bit loose than it should've been. The [relevant code snippet](https://github.com/commons-app/apps-android-commons/blob/15fa93e8d004bd0f55515e6d33c7228b23d63026/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt):

```kotlin
return item.matches(".*(19|20)\\d{2}.*".toRegex())
                && !item.contains(yearInString)
                && !item.contains(prevYearInString)
                || item.matches("(.*)needing(.*)".toRegex())
                || item.matches("(.*)taken on(.*)".toRegex())
                || item.matches(".*0s.*".toRegex())
                && !item.matches(".*(200|201)0s.*".toRegex())
```

That essentially returns true (which means the category is filtered out) when the name:

1. Contains "19xx" OR "20xx" AND
2. Does not contain current year AND
3. Does not contain previous year OR
4. Contains "needing" OR
5. Contains "take on" OR
6. Contains 0s AND
7. Does not contain "2000s" OR "2010s"

If we were to convert the implicit precedence to explicit ones the condition looks like this:

(((1) AND (2) AND (3)) OR (4) OR (5) OR ((6) AND (7)))

Given [short-circuiting evaluation](https://en.wikipedia.org/wiki/Short-circuit_evaluation), the condition should've returned `true` for "Amavenita (ship, 2014)" thus the category getting filtered out.

We could fix this by fixing the loose condition. I'll open a PR.
Thanks a lot @devarsh-mavani-19 for finding this out!
I had forgotten about this feature.
Do we all agree on this?
- `Photographs taken on 2015-11-08` -> Hide
- `Amavenita (ship, 2014)` -> Do not hide
> Do we all agree on this?

agree
What is the status of this?
@Discostu36 This bug still happens, I just tried on the latest Git code. Do you want to work on it? :-)
Sadly, I lack the skills. I have started learning Kotlin development, but I'm only at the very first baby steps (and don't have much time).
For those interested in picking this up, MR #4902 already has the changes to fix this. Only the tests need fixing IIRC. So, it could be used as a base.

I intend to get around to completing it. Do feel free to beat me to it, though 😉
I want to try ✋
Apart from filtering strings `needing` & `taken on` we have these filters

- years that ends with XXX0s & are above 2000s are allowed i.e 2000, 2010, 2020.
- Otherwise we only allow `current` or `previous year`.

Now, this issue needs the second condition to be removed i.e any year should be allowed. But then it also doesn't make sense to have filter for decades (in the first point) right?

### Solution:

We can remove both of the filters and allow any year between 19XX & 20XX (inclusive)

OR

Just have one filter to allow any year above >2000s instead of only `current` or `previous` year or in `decades` terms.

WDYT? @nicolas-raoul

Also, api only works correctly under `prod` because the query (Amavenita) mentioned in the issue was returning empty result in `beta` , shouldn't be an issue right?

@parneet-guraya If I understand correctly Sivaraam's comment above, only the unit tests need fixing. :-)

Further modifications might be worth a different issue.

Thanks!
> So, I'm starting to do you think if we could just take forward a combination of your changes (https://github.com/commons-app/apps-android-commons/pull/5761) and the one proposed in https://github.com/commons-app/apps-android-commons/pull/4902 in the next release and see how it goes 🤔

As per [comment](https://github.com/commons-app/apps-android-commons/issues/5749#issuecomment-2227279839), solution for this #4902 overlaps with the PR that was later merged #5761. I had to solve the conflict but solution made the other filters redundant that's why the comment (my comment above).

>Now, this issue needs the second condition to be removed i.e any year should be allowed. But then it also doesn't make sense to have filter for decades (in the first point) right?
I get what you're trying to say @parneet-guraya. The fix done via #5761 does conflict with the PR #4902 significantly. I think the change does need a bit of tweak as a consequence. I've merged recent main and tweaked the fix accordingly. Hope this helps you show a way out of the conflict.

./gradlew :app:testBetaDebugUnitTest --tests "fr.free.nrw.commons.category.CategoriesModelTest"

diff --git a/app/.attach_pid781771 b/app/.attach_pid781771
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
index fd90be95fd..47147944c6 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
@@ -36,37 +36,35 @@ class CategoriesModel
          * @return
          */
         fun isSpammyCategory(item: String): Boolean {
-            // Check for current and previous year to exclude these categories from removal
-            val now = Calendar.getInstance()
-            val curYear = now[Calendar.YEAR]
-            val curYearInString = curYear.toString()
-            val prevYear = curYear - 1
-            val prevYearInString = prevYear.toString()
-            Timber.d("Previous year: %s", prevYearInString)
-
-            val mentionsDecade = item.matches(".*0s.*".toRegex())
-            val recentDecade = item.matches(".*20[0-2]0s.*".toRegex())
-            val spammyCategory =
-                item.matches("(.*)needing(.*)".toRegex()) ||
-                    item.matches("(.*)taken on(.*)".toRegex())

             // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
+            val spammyCategory = item.matches("(.*)needing(.*)".toRegex())
+                    || item.matches("(.*)taken on(.*)".toRegex())
+
+            // checks for
+            // dd/mm/yyyy or yy
+            // yyyy or yy/mm/dd
+            // yyyy or yy/mm
+            // mm/yyyy or yy
+            // for `yy` it is assumed that 20XX is implicit.
+            // with separators [., /, -]
+            val isIrrelevantCategory =
+                item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex())
+
+
             if (spammyCategory) {
                 return true
             }

-            if (mentionsDecade) {
-                // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029
-                // Example: "2020s" is OK, but "1920s" is not (and should be skipped)
-                return !recentDecade
-            } else {
-                // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year
-                // anywhere within the string (.* is wildcard) (Issue #47)
-                // And that item does not equal the current year or previous year
-                return item.matches(".*(19|20)\\d{2}.*".toRegex()) &&
-                    !item.contains(curYearInString) &&
-                    !item.contains(prevYearInString)
+            if(isIrrelevantCategory){
+                return true
             }
+
+            val hasYear = item.matches("(.*\\d{4}.*)".toRegex())
+            val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex())
+
+            // finally if there's 4 digits year exists in XXXX it should only be in 20XX range.
+            return  hasYear && !validYearsRange
         }

         /**

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
index 8c336470a6..21fdba2f57 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
@@ -11,6 +11,7 @@ import fr.free.nrw.commons.upload.GpsCategoryModel
 import io.reactivex.Single
 import io.reactivex.subjects.BehaviorSubject
 import media
+import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
 import org.mockito.ArgumentMatchers
@@ -331,4 +332,42 @@ class CategoriesModelTest {
             media(),
         )
     }
+
+    @Test
+    fun `test valid input with XXXX in it between the expected range 20XX`() {
+        val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014)")
+        Assert.assertFalse(input)
+    }
+
+    @Test
+    fun `test valid input with XXXXs in it between the expected range 20XXs`() {
+        val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014s)")
+        Assert.assertFalse(input)
+    }
+
+    @Test
+    fun `test invalid category when have needing in the input`() {
+        val input = categoriesModel.isSpammyCategory("Media needing categories as of 30 March 2017")
+        Assert.assertTrue(input)
+    }
+
+    @Test
+    fun `test invalid category when have taken on in the input`() {
+        val input = categoriesModel.isSpammyCategory("Photographs taken on 2015-12-08")
+        Assert.assertTrue(input)
+    }
+
+    @Test
+    fun `test invalid category when have yy mm or yy mm dd in the input`() {
+        // filtering based on [., /, -]  separators between the dates.
+        val input = categoriesModel.isSpammyCategory("Image class 09.14")
+        Assert.assertTrue(input)
+    }
+
+    @Test
+    fun `test invalid category when have years not in 20XX range`() {
+        val input = categoriesModel.isSpammyCategory("Japan in the 1400s")
+        Assert.assertTrue(input)
+    }
+
 }


**Description (required)**

Resolves #5936

**Tests performed (required)**

Tested ProdDebug on Samsung S20FE with API level 33.

**Screenshots (for UI changes only)**
In the screenrecord below, the item [Warnitz (Uckermark) railway station](https://www.wikidata.org/wiki/Q7969836) has no commons category, so its [P18](https://commons.wikimedia.org/wiki/File:Bahnhof_warnitz.jpg)'s categories are listed. On the other hand, the item [human](https://www.wikidata.org/wiki/Q5) has a commons category, which is listed on the categories screen.

https://github.com/user-attachments/assets/a59f7fd2-1814-48f4-a3c9-0dbf4347b762

If depicted Wikidata item has no associated Commons category property, then suggest categories from its P18
### What is the user problem or growth opportunity you want to see solved?

Recently, the objects that already have an image are also displayed for searched images.

I would find it helpful if all categories of an image already available via Wikidata were offered for selection.

### How do you know that this problem exists today? Why is this important?

In my opinion, this would simplify the assignment and help to ensure that the most suitable categories are selected when uploading.

### Who will benefit from it?

all users

### Anything else you would like to add?

_No response_

I am not sure to understand. Would you mind editing to add a real-world example, citing the real-world objects/categories? Thanks!
@nicolas-raoul

There is already a picture (“Picture A”) for a Wikidata object (Q7969836).
I would like to upload another (“Picture B”)  picture related to the same Wikidata object (Q7969836) to Commons.
When uploading (“Picture B”), I want the Commons app to offer all the categories (first) in which “Picture A” is.

Exmaple:
* “Picture A”: https://commons.wikimedia.org/wiki/File:Bahnhof_warnitz.jpg
* “Picture B”:  https://commons.wikimedia.org/wiki/File:20241117_xl_0956-Haltepunkt_Warnitz_(Uckermark).jpg
** https://commons.wikimedia.org/w/index.php?title=File:20241117_xl_0956-Haltepunkt_Warnitz_(Uckermark).jpg&action=history

(Is that easier to understand?)
Good idea, thanks!
Especially useful when a Wikidata item does not have a dedicated Commons category.
I'd like to try

./gradlew :app:testBetaDebugUnitTest --tests "fr.free.nrw.commons.category.CategoriesModelTest" --tests "fr.free.nrw.commons.upload.structure.depictions.DepictedItemTest"

diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
index 7e6fee2fca..fd90be95fd 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
@@ -127,30 +127,64 @@ class CategoriesModel
         /**
          * Fetches details of every category associated with selected depictions, converts them into
          * CategoryItem and returns them in a list.
+         * If a selected depiction has no categories, the categories in which its P18 belongs are
+         * returned in the list.
          *
          * @param selectedDepictions selected DepictItems
          * @return List of CategoryItem associated with selected depictions
          */
-        private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? =
-            Observable
-                .fromIterable(
-                    selectedDepictions.map { it.commonsCategories }.flatten(),
-                ).map { categoryItem ->
-                    categoryClient
-                        .getCategoriesByName(
-                            categoryItem.name,
-                            categoryItem.name,
-                            SEARCH_CATS_LIMIT,
-                        ).map {
-                            CategoryItem(
-                                it[0].name,
-                                it[0].description,
-                                it[0].thumbnail,
-                                it[0].isSelected,
-                            )
-                        }.blockingGet()
-                }.toList()
-                .toObservable()
+        private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? {
+            val observables =  selectedDepictions.map { depictedItem ->
+                if (depictedItem.commonsCategories.isEmpty()) {
+                    if (depictedItem.primaryImage == null) {
+                        return@map Observable.just(emptyList<CategoryItem>())
+                    }
+                    Observable.just(
+                            depictedItem.primaryImage
+                        ).map { image ->
+                            categoryClient
+                                .getCategoriesOfImage(
+                                    image,
+                                    SEARCH_CATS_LIMIT,
+                                ).map {
+                                    it.map { category ->
+                                        CategoryItem(
+                                            category.name,
+                                            category.description,
+                                            category.thumbnail,
+                                            category.isSelected,
+                                        )
+                                    }
+                                }.blockingGet()
+                        }.flatMapIterable { it }.toList()
+                        .toObservable()
+                } else {
+                    Observable
+                        .fromIterable(
+                            depictedItem.commonsCategories,
+                        ).map { categoryItem ->
+                            categoryClient
+                                .getCategoriesByName(
+                                    categoryItem.name,
+                                    categoryItem.name,
+                                    SEARCH_CATS_LIMIT,
+                                ).map {
+                                    CategoryItem(
+                                        it[0].name,
+                                        it[0].description,
+                                        it[0].thumbnail,
+                                        it[0].isSelected,
+                                    )
+                                }.blockingGet()
+                        }.toList()
+                        .toObservable()
+                }
+            }
+            return Observable.concat(observables)
+                .scan(mutableListOf<CategoryItem>()) { accumulator, currentList ->
+                    accumulator.apply { addAll(currentList) }
+                }
+        }

         /**
          * Fetches details of every category by their name, converts them into
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
index 5571e0ea77..b031f12f13 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
@@ -78,6 +78,24 @@ class CategoryClient
                 ),
             )

+        /**
+         * Fetches categories belonging to an image (P18 of some wikidata entity).
+         *
+         * @param image P18 of some wikidata entity
+         * @param itemLimit How many categories to return
+         * @return Single Observable emitting the list of categories
+         */
+        fun getCategoriesOfImage(
+            image: String,
+            itemLimit: Int,
+        ): Single<List<CategoryItem>> =
+            responseMapper(
+                categoryInterface.getCategoriesByTitles(
+                    "File:${image}",
+                    itemLimit,
+                ),
+            )
+
         /**
          * The method takes categoryName as input and returns a List of Subcategories
          * It uses the generator query API to get the subcategories in a category, 500 at a time.
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
index aa5ac8c3ad..3888ef889e 100644
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
@@ -61,6 +61,21 @@ interface CategoryInterface {
         @Query("gacoffset") offset: Int,
     ): Single<MwQueryResponse>

+    /**
+     * Fetches non-hidden categories by titles.
+     *
+     * @param titles titles to fetch categories for (e.g. File:<P18 of a wikidata entity>)
+     * @param itemLimit How many categories to return
+     * @return MwQueryResponse
+     */
+    @GET(
+        "w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden",
+    )
+    fun getCategoriesByTitles(
+        @Query("titles") titles: String?,
+        @Query("gcllimit") itemLimit: Int,
+    ): Single<MwQueryResponse>
+
     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
     fun getSubCategoryList(
         @Query("gcmtitle") categoryName: String,
diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt
index 0e4ed482d2..4ae366535f 100644
--- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt
+++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt
@@ -68,6 +68,9 @@ data class DepictedItem constructor(
         entity.id(),
     )

+    val primaryImage: String?
+        get() = imageUrl?.split('-')?.lastOrNull()
+
     override fun equals(other: Any?) =
         when {
             this === other -> true

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
index cc5340fbb7..8c336470a6 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt
@@ -1,7 +1,9 @@
 package fr.free.nrw.commons.category

 import categoryItem
+import com.nhaarman.mockitokotlin2.any
 import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.times
 import com.nhaarman.mockitokotlin2.verify
 import com.nhaarman.mockitokotlin2.whenever
 import depictedItem
@@ -90,14 +92,18 @@ class CategoriesModelTest {
         val depictedItem =
             depictedItem(
                 commonsCategories =
-                    listOf(
-                        CategoryItem(
-                            "depictionCategory",
-                            "",
-                            "",
-                            false,
-                        ),
+                listOf(
+                    CategoryItem(
+                        "depictionCategory",
+                        "",
+                        "",
+                        false,
                     ),
+                ),
+            )
+        val depictedItemWithoutCategories =
+            depictedItem(
+                imageUrl = "testUrl"
             )

         whenever(gpsCategoryModel.categoriesFromLocation)
@@ -159,6 +165,23 @@ class CategoriesModelTest {
                 ),
             ),
         )
+        whenever(
+            categoryClient.getCategoriesOfImage(
+                "testUrl",
+                25,
+            ),
+        ).thenReturn(
+            Single.just(
+                listOf(
+                    CategoryItem(
+                        "categoriesOfP18",
+                        "",
+                        "",
+                        false,
+                    ),
+                ),
+            ),
+        )
         val imageTitleList = listOf("Test")
         CategoriesModel(categoryClient, categoryDao, gpsCategoryModel)
             .searchAll("", imageTitleList, listOf(depictedItem))
@@ -171,8 +194,21 @@ class CategoriesModelTest {
                     categoryItem("recentCategories"),
                 ),
             )
+        CategoriesModel(categoryClient, categoryDao, gpsCategoryModel)
+            .searchAll("", imageTitleList, listOf(depictedItemWithoutCategories))
+            .test()
+            .assertValue(
+                listOf(
+                    categoryItem("categoriesOfP18"),
+                    categoryItem("gpsCategory"),
+                    categoryItem("titleSearch"),
+                    categoryItem("recentCategories"),
+                ),
+            )
         imageTitleList.forEach {
-            verify(categoryClient).searchCategories(it, CategoriesModel.SEARCH_CATS_LIMIT)
+            verify(categoryClient, times(2)).searchCategories(it, CategoriesModel.SEARCH_CATS_LIMIT)
+            verify(categoryClient).getCategoriesByName(any(), any(), any(), any())
+            verify(categoryClient).getCategoriesOfImage(any(), any())
         }
     }

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt
index 5c95215a87..5edf55f6ed 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryClientTest.kt
@@ -132,6 +132,45 @@ class CategoryClientTest {
             )
     }

+    @Test
+    fun getCategoriesByTitlesFound() {
+        val mockResponse = withMockResponse("Category:Test")
+        whenever(
+            categoryInterface.getCategoriesByTitles(
+                anyString(),
+                anyInt(),
+            ),
+        ).thenReturn(Single.just(mockResponse))
+        categoryClient
+            .getCategoriesOfImage("tes", 10)
+            .test()
+            .assertValues(
+                listOf(
+                    CategoryItem(
+                        "Test",
+                        "",
+                        "",
+                        false,
+                    ),
+                ),
+            )
+        categoryClient
+            .getCategoriesOfImage(
+                "tes",
+                10,
+            ).test()
+            .assertValues(
+                listOf(
+                    CategoryItem(
+                        "Test",
+                        "",
+                        "",
+                        false,
+                    ),
+                ),
+            )
+    }
+
     @Test
     fun getCategoriesByNameNull() {
         val mockResponse = withNullPages()
@@ -160,6 +199,29 @@ class CategoryClientTest {
             .assertValues(emptyList())
     }

+    @Test
+    fun getCategoriesByTitlesNull() {
+        val mockResponse = withNullPages()
+        whenever(
+            categoryInterface.getCategoriesByTitles(
+                anyString(),
+                anyInt(),
+            ),
+        ).thenReturn(Single.just(mockResponse))
+        categoryClient
+            .getCategoriesOfImage(
+                "tes",
+                10,
+            ).test()
+            .assertValues(emptyList())
+        categoryClient
+            .getCategoriesOfImage(
+                "tes",
+                10,
+            ).test()
+            .assertValues(emptyList())
+    }
+
     @Test
     fun getParentCategoryListFound() {
         val mockResponse = withMockResponse("Category:Test")
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/structure/depictions/DepictedItemTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/structure/depictions/DepictedItemTest.kt
index e0d339eee8..faec520519 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/upload/structure/depictions/DepictedItemTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/structure/depictions/DepictedItemTest.kt
@@ -181,4 +181,20 @@ class DepictedItemTest {
     fun `hashCode returns different values for objects with different name`() {
         Assert.assertNotEquals(depictedItem(name = "a").hashCode(), depictedItem(name = "b").hashCode())
     }
+
+    @Test
+    fun `primaryImage is derived correctly from imageUrl`() {
+        Assert.assertEquals(
+            DepictedItem(
+                entity(
+                    statements = mapOf(
+                        WikidataProperties.IMAGE.propertyName to listOf(
+                            statement(snak(dataValue = valueString("prefix: example_image name"))),
+                        ),
+                    ),
+                ),
+            ).primaryImage,
+            "_example_image_name",
+        )
+    }
 }