Nearby: No longer keeps loading until timeout when map is zoomed out

**Description (required)**

Fixes #6044

Put briefly, the map now only searches in up to 300km radius centered on the screen's center.

The change wasnn't that simple, as loading the list of items even for a 50km radius can result in timeout for some locations, so I implemented an algorithm based on binary search to figure out the appropriate radius to list items inside, by first making some COUNT queries. Also, the centermost pins now appear on top.

**Tests performed (required)**

Tested BetaDebug on Medium Phone AVD and a Physical Device with API levels 34 and 33 respectively.

**Screenshots (for UI changes only)**

https://github.com/user-attachments/assets/e589876f-2853-4677-81d9-556eb358d4b0

Nearby: Flawed initial loading of pins.

Nearby: Flawed initial loading of pins.
Currently, in nearby, when a user scrolls (which also includes resizing) to a new position in the map), a query is made for items in the rectangular geographical area. Although the query is lightweight (made for only P625), it goes on endlessly until timeout for large enough areas.

When a user zooms out enough, the initial query is made for a large area, so the query takes a long time only to end up in a timeout. When the user then zooms in, a second query should be initiated (discarding the first), but it never happens as per the current logic, which ideally shouldn't happen.
This part is easy to fix (by ignoring currently running query after a shorter timeout).

But ideally, the expensive query should never be made in the first place. Reason:
Say, the area is small enough for the query to return (but still large enough for it to return much greater than 1000 items).
Then the resulting items are sorted in-app (by their distance from the map centre), and only the nearest 1000 items are actually utilized.

A workaround would be to first query for the items count. If it's much greater than 1000, then query on a smaller square bound centered on the user (in the same manner as a _binary search_). Do it till the number of items in the queried area is under $`1000 \times \frac{4}{\pi}`$.

Here's an example:
- [The initial query in its current form](https://query.wikidata.org/sparql?query=SELECT%0A%20%20%3Fitem%0A%20%20%28SAMPLE%28%3Flocation%29%20as%20%3Flocation%29%0AWHERE%20%7B%0A%20%20%23%20Around%20given%20location%0A%20%20SERVICE%20wikibase%3Abox%20%7B%0A%20%20%20%20%3Fitem%20wdt%3AP625%20%3Flocation.%0A%20%20%20%20%20bd%3AserviceParam%20wikibase%3AcornerWest%20%22Point%282.3321%2048.8376%29%22%5E%5Egeo%3AwktLiteral.%0A%20%20%20%20%20bd%3AserviceParam%20wikibase%3AcornerEast%20%22Point%282.3545%2048.8625%29%22%5E%5Egeo%3AwktLiteral.%0A%20%20%7D%0A%0A%7D%0AGROUP%20BY%20%3Fitem%0A&format=json)
- [The proposed initial query (which returns only the count)](https://query.wikidata.org/sparql?query=SELECT%20(COUNT(DISTINCT%20%3fitem)%20AS%20%3fcount)%0d%0aWHERE%20%7b%0d%0a%20%20SERVICE%20wikibase%3abox%20%7b%0d%0a%20%20%20%20%3fitem%20wdt%3aP625%20%3flocation.%0d%0a%20%20%20%20bd%3aserviceParam%20wikibase%3acornerWest%20%22Point(2.3321%2048.8376)%22%5e%5egeo%3awktLiteral.%0d%0a%20%20%20%20bd%3aserviceParam%20wikibase%3acornerEast%20%22Point(2.3545%2048.8625)%22%5e%5egeo%3awktLiteral.%0d%0a%20%20%7d%0d%0a%7d%0d%0a&format=json)

Also, if the area is absurdly big, then even the count-binary-search queries (proposed above) can be dropped and a simple overlay saying "zoom in to display pins" can be shown (maybe similar to how chromium based browsers show "No Internet Connection").

Great ideas! I totally agree with all of this.
Do you want to be assigned? (please mention it when creating issues)
> Do you want to be assigned? (please mention it when creating issues)

Yes, I'll be glad to implement this. I'll make sure to mention if I’d like to be assigned in future issues.

diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt
index 415d316711..291c834bdc 100644
--- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt
+++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt
@@ -2,6 +2,7 @@ package fr.free.nrw.commons.mwapi

 import android.text.TextUtils
 import com.google.gson.Gson
+import com.google.gson.JsonParser
 import fr.free.nrw.commons.BuildConfig
 import fr.free.nrw.commons.campaigns.CampaignResponseDTO
 import fr.free.nrw.commons.explore.depictions.DepictsClient
@@ -10,6 +11,7 @@ import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse
 import fr.free.nrw.commons.location.LatLng
 import fr.free.nrw.commons.nearby.Place
 import fr.free.nrw.commons.nearby.model.ItemsClass
+import fr.free.nrw.commons.nearby.model.NearbyQueryParams
 import fr.free.nrw.commons.nearby.model.NearbyResponse
 import fr.free.nrw.commons.nearby.model.PlaceBindings
 import fr.free.nrw.commons.profile.achievements.FeaturedImages
@@ -330,36 +332,130 @@ class OkHttpJsonApiClient @Inject constructor(
         throw Exception(response.message)
     }

+    /**
+     * Returns the count of items in the specified area by querying Wikidata.
+     *
+     * @param queryParams: a `NearbyQueryParam` specifying the geographical area.
+     * @return The count of items in the specified area.
+     */
+    @Throws(Exception::class)
+    fun getNearbyItemCount(
+        queryParams: NearbyQueryParams
+    ): Int {
+        val wikidataQuery: String = when (queryParams) {
+            is NearbyQueryParams.Rectangular -> {
+                val westCornerLat = queryParams.screenTopRight.latitude
+                val westCornerLong = queryParams.screenTopRight.longitude
+                val eastCornerLat = queryParams.screenBottomLeft.latitude
+                val eastCornerLong = queryParams.screenBottomLeft.longitude
+                FileUtils.readFromResource("/queries/rectangle_query_for_item_count.rq")
+                    .replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
+                    .replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
+                    .replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
+                    .replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
+            }
+
+            is NearbyQueryParams.Radial -> {
+                FileUtils.readFromResource("/queries/radius_query_for_item_count.rq")
+                    .replace(
+                        "\${LAT}",
+                        String.format(Locale.ROOT, "%.4f", queryParams.center.latitude)
+                    )
+                    .replace(
+                        "\${LONG}",
+                        String.format(Locale.ROOT, "%.4f", queryParams.center.longitude)
+                    )
+                    .replace("\${RAD}", String.format(Locale.ROOT, "%.2f", queryParams.radiusInKm))
+            }
+        }
+
+        val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
+            .newBuilder()
+            .addQueryParameter("query", wikidataQuery)
+            .addQueryParameter("format", "json")
+
+        val request: Request = Request.Builder()
+            .url(urlBuilder.build())
+            .build()
+
+        val response = okHttpClient.newCall(request).execute()
+        if (response.body != null && response.isSuccessful) {
+            val json = response.body!!.string()
+            return JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("results")
+                .getAsJsonArray("bindings").get(0).getAsJsonObject().getAsJsonObject("itemCount")
+                .get("value").asInt
+        }
+        throw Exception(response.message)
+    }
+
     @Throws(Exception::class)
     fun getNearbyPlaces(
-        screenTopRight: LatLng,
-        screenBottomLeft: LatLng, language: String,
+        queryParams: NearbyQueryParams, language: String,
         shouldQueryForMonuments: Boolean, customQuery: String?
     ): List<Place>? {
         Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString())

+        val locale = Locale.ROOT;
         val wikidataQuery: String = if (customQuery != null) {
-            customQuery
-        } else if (!shouldQueryForMonuments) {
-            FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq")
-        } else {
-            FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq")
-        }
+                when (queryParams) {
+                    is NearbyQueryParams.Rectangular -> {
+                        val westCornerLat = queryParams.screenTopRight.latitude
+                        val westCornerLong = queryParams.screenTopRight.longitude
+                        val eastCornerLat = queryParams.screenBottomLeft.latitude
+                        val eastCornerLong = queryParams.screenBottomLeft.longitude
+                        customQuery
+                            .replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
+                            .replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
+                            .replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
+                            .replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
+                            .replace("\${LANG}", language)
+                    }
+                    is NearbyQueryParams.Radial -> {
+                        Timber.e(
+                            "%s%s",
+                            "okHttpJsonApiClient.getNearbyPlaces invoked with custom query",
+                            "and radial coordinates. This is currently not supported."
+                        )
+                        ""
+                    }
+                }
+        } else when (queryParams) {
+            is NearbyQueryParams.Radial -> {
+                val placeHolderQuery: String = if (!shouldQueryForMonuments) {
+                    FileUtils.readFromResource("/queries/radius_query_for_nearby.rq")
+                } else {
+                    FileUtils.readFromResource("/queries/radius_query_for_nearby_monuments.rq")
+                }
+                placeHolderQuery.replace(
+                        "\${LAT}", String.format(locale, "%.4f", queryParams.center.latitude)
+                    ).replace(
+                        "\${LONG}", String.format(locale, "%.4f", queryParams.center.longitude)
+                    )
+                    .replace("\${RAD}", String.format(locale, "%.2f", queryParams.radiusInKm))
+            }

-        val westCornerLat = screenTopRight.latitude
-        val westCornerLong = screenTopRight.longitude
-        val eastCornerLat = screenBottomLeft.latitude
-        val eastCornerLong = screenBottomLeft.longitude
+            is NearbyQueryParams.Rectangular -> {
+                val placeHolderQuery: String = if (!shouldQueryForMonuments) {
+                    FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq")
+                } else {
+                    FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq")
+                }
+                val westCornerLat = queryParams.screenTopRight.latitude
+                val westCornerLong = queryParams.screenTopRight.longitude
+                val eastCornerLat = queryParams.screenBottomLeft.latitude
+                val eastCornerLong = queryParams.screenBottomLeft.longitude
+                placeHolderQuery
+                    .replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
+                    .replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
+                    .replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
+                    .replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
+                    .replace("\${LANG}", language)
+            }
+        }

-        val query = wikidataQuery
-            .replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
-            .replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
-            .replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
-            .replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
-            .replace("\${LANG}", language)
         val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
             .newBuilder()
-            .addQueryParameter("query", query)
+            .addQueryParameter("query", wikidataQuery)
             .addQueryParameter("format", "json")

         val request: Request = Request.Builder()
diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java
index cc2442db60..4038135195 100644
--- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java
+++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java
@@ -196,8 +196,9 @@ public NearbyPlacesInfo loadAttractionsFromLocation(final LatLng currentLatLng,
             return null;
         }

-        List<Place> places = nearbyPlaces.getFromWikidataQuery(screenTopRight, screenBottomLeft,
-            Locale.getDefault().getLanguage(), shouldQueryForMonuments, customQuery);
+        List<Place> places = nearbyPlaces.getFromWikidataQuery(currentLatLng, screenTopRight,
+            screenBottomLeft, Locale.getDefault().getLanguage(), shouldQueryForMonuments,
+            customQuery);

         if (null != places && places.size() > 0) {
             LatLng[] boundaryCoordinates = {
diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java
index 46f0a2a9eb..caae8ee45f 100644
--- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java
+++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java
@@ -1,6 +1,8 @@
 package fr.free.nrw.commons.nearby;

+import android.location.Location;
 import androidx.annotation.Nullable;
+import fr.free.nrw.commons.nearby.model.NearbyQueryParams;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
@@ -101,6 +103,7 @@ public List<Place> getFromWikidataQuery(final LatLng cur, final String lang,
      * Retrieves a list of places from a Wikidata query based on screen coordinates and optional
      * parameters.
      *
+     * @param centerPoint             The center of the map, used for radius queries if required.
      * @param screenTopRight          The top right corner of the screen (latitude, longitude).
      * @param screenBottomLeft        The bottom left corner of the screen (latitude, longitude).
      * @param lang                    The language for the query.
@@ -111,13 +114,70 @@ public List<Place> getFromWikidataQuery(final LatLng cur, final String lang,
      * @throws Exception If an error occurs during the retrieval process.
      */
     public List<Place> getFromWikidataQuery(
+        final fr.free.nrw.commons.location.LatLng centerPoint,
         final fr.free.nrw.commons.location.LatLng screenTopRight,
         final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang,
         final boolean shouldQueryForMonuments,
         @Nullable final String customQuery) throws Exception {
-        return okHttpJsonApiClient
-            .getNearbyPlaces(screenTopRight, screenBottomLeft, lang, shouldQueryForMonuments,
-                customQuery);
+        if (customQuery != null) {
+            return okHttpJsonApiClient
+                .getNearbyPlaces(
+                    new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
+                    shouldQueryForMonuments,
+                    customQuery);
+        }
+
+        final int lowerLimit = 1000, upperLimit = 1500;
+
+        final float[] results = new float[1];
+        Location.distanceBetween(centerPoint.getLatitude(), screenTopRight.getLongitude(),
+            centerPoint.getLatitude(), screenBottomLeft.getLongitude(), results);
+        final float longGap = results[0] / 1000f;
+        Location.distanceBetween(screenTopRight.getLatitude(), centerPoint.getLongitude(),
+            screenBottomLeft.getLatitude(), centerPoint.getLongitude(), results);
+        final float latGap = results[0] / 1000f;
+
+        if (Math.max(longGap, latGap) < 100f) {
+            final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
+                new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft));
+            if (itemCount < upperLimit) {
+                return okHttpJsonApiClient.getNearbyPlaces(
+                    new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
+                    shouldQueryForMonuments, null);
+            }
+        }
+
+        // minRadius, targetRadius and maxRadius are radii in decameters
+        // unlike other radii here, which are in kilometers, to avoid looping over
+        // floating point values
+        int minRadius = 0, maxRadius = Math.round(Math.min(300f, Math.min(longGap, latGap))) * 100;
+        int targetRadius = maxRadius / 2;
+        while (minRadius < maxRadius) {
+            targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
+            final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
+                new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f));
+            if (itemCount >= lowerLimit && itemCount < upperLimit) {
+                break;
+            }
+            if (targetRadius > maxRadius / 2 && itemCount < lowerLimit / 5) { // fast forward
+                minRadius = targetRadius;
+                targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
+                minRadius = targetRadius;
+                if (itemCount < lowerLimit / 10 && minRadius < maxRadius) { // fast forward again
+                    targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
+                    minRadius = targetRadius;
+                }
+                continue;
+            }
+            if (itemCount < upperLimit) {
+                minRadius = targetRadius;
+            } else {
+                maxRadius = targetRadius - 1;
+            }
+        }
+        return okHttpJsonApiClient.getNearbyPlaces(
+            new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f), lang, shouldQueryForMonuments,
+            null);
     }

     /**
diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java
index ab3d9a049e..a865a07893 100644
--- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java
@@ -1101,19 +1101,19 @@ public void populatePlaces(final LatLng currentLatLng) {
                 eastCornerLong, 0);
             if (currentLatLng.equals(
                 getLastMapFocus())) { // Means we are checking around current location
-                populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng,
+                populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
                     screenBottomLeftLatLng, currentLatLng, null);
             } else {
-                populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng,
+                populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
                     screenBottomLeftLatLng, currentLatLng, null);
             }
         } else {
             if (currentLatLng.equals(
                 getLastMapFocus())) { // Means we are checking around current location
-                populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng,
+                populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
                     screenBottomLeftLatLng, currentLatLng, null);
             } else {
-                populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng,
+                populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
                     screenBottomLeftLatLng, currentLatLng, null);
             }
         }
@@ -1887,9 +1887,12 @@ public Marker convertToMarker(Place place, boolean isBookMarked) {
     @Override
     public void replaceMarkerOverlays(final List<MarkerPlaceGroup> markerPlaceGroups) {
         ArrayList<Marker> newMarkers = new ArrayList<>(markerPlaceGroups.size());
-        for (MarkerPlaceGroup markerPlaceGroup : markerPlaceGroups) {
+        // iterate in reverse so that the nearest pins get rendered on top
+        for (int i = markerPlaceGroups.size() - 1; i >= 0; i--) {
             newMarkers.add(
-                convertToMarker(markerPlaceGroup.getPlace(), markerPlaceGroup.getIsBookmarked()));
+                convertToMarker(markerPlaceGroups.get(i).getPlace(),
+                markerPlaceGroups.get(i).getIsBookmarked())
+            );
         }
         clearAllMarkers();
         binding.map.getOverlays().addAll(newMarkers);
diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyQueryParams.kt b/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyQueryParams.kt
new file mode 100644
index 0000000000..eb1d7cc70f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyQueryParams.kt
@@ -0,0 +1,10 @@
+package fr.free.nrw.commons.nearby.model
+
+import fr.free.nrw.commons.location.LatLng
+
+sealed class NearbyQueryParams {
+    class Rectangular(val screenTopRight: LatLng, val screenBottomLeft: LatLng) :
+        NearbyQueryParams()
+
+    class Radial(val center: LatLng, val radiusInKm: Float) : NearbyQueryParams()
+}
\ No newline at end of file
diff --git a/app/src/main/resources/queries/radius_query_for_item_count.rq b/app/src/main/resources/queries/radius_query_for_item_count.rq
new file mode 100644
index 0000000000..427b727953
--- /dev/null
+++ b/app/src/main/resources/queries/radius_query_for_item_count.rq
@@ -0,0 +1,9 @@
+SELECT (COUNT(?item) AS ?itemCount)
+WHERE {
+  # Around given location.
+  SERVICE wikibase:around {
+    ?item wdt:P625 ?location.
+    bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
+    bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/resources/queries/radius_query_for_nearby.rq b/app/src/main/resources/queries/radius_query_for_nearby.rq
new file mode 100644
index 0000000000..86c4ff602d
--- /dev/null
+++ b/app/src/main/resources/queries/radius_query_for_nearby.rq
@@ -0,0 +1,12 @@
+SELECT
+  ?item
+  (SAMPLE(?location) as ?location)
+WHERE {
+  # Around given location.
+  SERVICE wikibase:around {
+    ?item wdt:P625 ?location.
+    bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
+    bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
+  }
+}
+GROUP BY ?item
\ No newline at end of file
diff --git a/app/src/main/resources/queries/radius_query_for_nearby_monuments.rq b/app/src/main/resources/queries/radius_query_for_nearby_monuments.rq
new file mode 100644
index 0000000000..84b3095af2
--- /dev/null
+++ b/app/src/main/resources/queries/radius_query_for_nearby_monuments.rq
@@ -0,0 +1,25 @@
+SELECT
+  ?item
+  (SAMPLE(?location) as ?location)
+  (SAMPLE(?monument) AS ?monument)
+WHERE {
+  # Around given location.
+  SERVICE wikibase:around {
+    ?item wdt:P625 ?location.
+    bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
+    bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
+  }
+
+  # Wiki Loves Monuments
+  OPTIONAL {?item p:P1435 ?monument}
+  OPTIONAL {?item p:P2186 ?monument}
+  OPTIONAL {?item p:P1459 ?monument}
+  OPTIONAL {?item p:P1460 ?monument}
+  OPTIONAL {?item p:P1216 ?monument}
+  OPTIONAL {?item p:P709 ?monument}
+  OPTIONAL {?item p:P718 ?monument}
+  OPTIONAL {?item p:P5694 ?monument}
+  OPTIONAL {?item p:P3426 ?monument}
+
+}
+GROUP BY ?item
\ No newline at end of file
diff --git a/app/src/main/resources/queries/rectangle_query_for_item_count.rq b/app/src/main/resources/queries/rectangle_query_for_item_count.rq
new file mode 100644
index 0000000000..93b9385e79
--- /dev/null
+++ b/app/src/main/resources/queries/rectangle_query_for_item_count.rq
@@ -0,0 +1,8 @@
+SELECT (COUNT(?item) AS ?itemCount)
+WHERE {
+  SERVICE wikibase:box {
+    ?item wdt:P625 ?location.
+    bd:serviceParam wikibase:cornerWest "Point(${LONG_WEST} ${LAT_WEST})"^^geo:wktLiteral.
+    bd:serviceParam wikibase:cornerEast "Point(${LONG_EAST} ${LAT_EAST})"^^geo:wktLiteral.
+  }
+}
\ No newline at end of file
diff --git a/app/src/main/resources/queries/rectangle_query_for_nearby.rq b/app/src/main/resources/queries/rectangle_query_for_nearby.rq
index 336b22db77..f8c70c74ad 100644
--- a/app/src/main/resources/queries/rectangle_query_for_nearby.rq
+++ b/app/src/main/resources/queries/rectangle_query_for_nearby.rq
@@ -8,6 +8,5 @@ WHERE {
      bd:serviceParam wikibase:cornerWest "Point(${LONG_WEST} ${LAT_WEST})"^^geo:wktLiteral.
      bd:serviceParam wikibase:cornerEast "Point(${LONG_EAST} ${LAT_EAST})"^^geo:wktLiteral.
   }
-
 }
 GROUP BY ?item
\ 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 3431efd6b8..9e78915603 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/OkHttpJsonApiClientTests.kt
@@ -6,6 +6,7 @@ import com.nhaarman.mockitokotlin2.verify
 import fr.free.nrw.commons.explore.depictions.DepictsClient
 import fr.free.nrw.commons.location.LatLng
 import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
+import fr.free.nrw.commons.nearby.model.NearbyQueryParams
 import okhttp3.Call
 import okhttp3.HttpUrl
 import okhttp3.OkHttpClient
@@ -14,6 +15,7 @@ import org.junit.Before
 import org.junit.Test
 import org.mockito.Mock
 import org.mockito.Mockito
+import org.mockito.Mockito.times
 import org.mockito.MockitoAnnotations
 import java.lang.Exception

@@ -64,12 +66,16 @@ class OkHttpJsonApiClientTests {
         Mockito.`when`(response.message).thenReturn("test")
         try {
             okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, "test")
-            okHttpJsonApiClient.getNearbyPlaces(latLng, latLng, "test", true, "test")
         } catch (e: Exception) {
             assert(e.message.equals("test"))
         }
-        verify(okhttpClient).newCall(any())
-        verify(call).execute()
+        try {
+            okHttpJsonApiClient.getNearbyPlaces(NearbyQueryParams.Rectangular(latLng, latLng), "test", true, "test")
+        } catch (e: Exception) {
+            assert(e.message.equals("test"))
+        }
+        verify(okhttpClient, times(2)).newCall(any())
+        verify(call, times(2)).execute()
     }

     @Test
@@ -77,11 +83,48 @@ class OkHttpJsonApiClientTests {
         Mockito.`when`(response.message).thenReturn("test")
         try {
             okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, null)
-            okHttpJsonApiClient.getNearbyPlaces(latLng, latLng, "test", true, null)
         } catch (e: Exception) {
             assert(e.message.equals("test"))
         }
-        verify(okhttpClient).newCall(any())
-        verify(call).execute()
+        try {
+            okHttpJsonApiClient.getNearbyPlaces(
+                NearbyQueryParams.Rectangular(latLng, latLng),
+                "test",
+                true,
+                null
+            )
+
+        } catch (e: Exception) {
+            assert(e.message.equals("test"))
+        }
+        try {
+            okHttpJsonApiClient.getNearbyPlaces(
+                NearbyQueryParams.Radial(latLng, 10f),
+                "test",
+                true,
+                null
+            )
+        } catch (e: Exception) {
+            assert(e.message.equals("test"))
+        }
+        verify(okhttpClient, times(3)).newCall(any())
+        verify(call, times(3)).execute()
+    }
+
+    @Test
+    fun testGetNearbyItemCount() {
+        Mockito.`when`(response.message).thenReturn("test")
+        try {
+            okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Radial(latLng, 10f))
+        } catch (e: Exception) {
+            assert(e.message.equals("test"))
+        }
+        try {
+            okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Rectangular(latLng, latLng))
+        } catch (e: Exception) {
+            assert(e.message.equals("test"))
+        }
+        verify(okhttpClient, times(2)).newCall(any())
+        verify(call, times(2)).execute()
     }
 }


Add pull down to refresh in Contributions screen

Fixes #5131

What changes did you make and why?

Add pull down to refresh in the contributions screen to fetch the latest data.

**Screenshots (for UI changes only)**

https://github.com/user-attachments/assets/a49cfc77-294a-45e0-a877-01c51a81cc7a

Add pull down sync

Add pull down sync
Install the app on two different cell phones. Take some pictures and upload them on one cell phone, then do the same on the different cell phone.

Now back on the first cell phone pull down on the screen (refresh action), expecting to be able to see the pictures that one took on the other cell phone.

Not only does the screen not pull down to refresh, the other pictures don't suddenly arrive either.

(Workaround: log out and then log in again. Your latest contributions from outside the app will then appear. )
@nicolas-raoul Can you please assign this issue to me?

I believe we can use SwipeRefreshLayout (Released by Android) to implement it.
@nicolas-raoul I am able to implement the swipe-to-refresh functionality but I'm currently facing an issue.

I was trying to call the initRecyclerView method on refreshing to fetch the contributions list and notify to update with the new list of contributions. Line -  https://github.com/commons-app/apps-android-commons/blob/9d0614e73466f6c3a524acce2540f17d72778dff/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java#L179

The method internally calls - https://github.com/commons-app/apps-android-commons/blob/9d0614e73466f6c3a524acce2540f17d72778dff/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java#L74

The output of repository.fetchContributions() from the second device however does not give the updated contributions as the observed contribution list size was still the same.

@kartikaykaushik14 I see this is still assigned to you, do you plan to work on it? :-)
Let me do this. While I'm at it, I will write it in Jetpack compose :)

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

diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
index 3f7bffe91b..b5075a21e0 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
@@ -2,7 +2,6 @@ package fr.free.nrw.commons.contributions

 import androidx.paging.PagedList.BoundaryCallback
 import fr.free.nrw.commons.auth.SessionManager
-import fr.free.nrw.commons.di.CommonsApplicationModule
 import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
 import fr.free.nrw.commons.media.MediaClient
 import io.reactivex.Scheduler
@@ -31,10 +30,7 @@ class ContributionBoundaryCallback
          * network
          */
         override fun onZeroItemsLoaded() {
-            if (sessionManager.userName != null) {
-                mediaClient.resetUserNameContinuation(sessionManager.userName!!)
-            }
-            fetchContributions()
+            refreshList()
         }

         /**
@@ -51,10 +47,26 @@ class ContributionBoundaryCallback
             fetchContributions()
         }

+        /**
+         * Fetch list from network and save it to local DB.
+         *
+         * @param onRefreshFinish callback to invoke when operations finishes
+         * with either error or success.
+         */
+        fun refreshList(onRefreshFinish: () -> Unit = {}){
+            if (sessionManager.userName != null) {
+                mediaClient.resetUserNameContinuation(sessionManager.userName!!)
+            }
+            fetchContributions(onRefreshFinish)
+        }
+
         /**
          * Fetches contributions using the MediaWiki API
+         *
+         *   @param onRefreshFinish callback to invoke when operations finishes
+         *   with either error or success.
          */
-        private fun fetchContributions() {
+        private fun fetchContributions(onRefreshFinish: () -> Unit = {}) {
             if (sessionManager.userName != null) {
                 userName
                     ?.let { userName ->
@@ -65,12 +77,15 @@ class ContributionBoundaryCallback
                                     Contribution(media = media, state = Contribution.STATE_COMPLETED)
                                 }
                             }.subscribeOn(ioThreadScheduler)
-                            .subscribe(::saveContributionsToDB) { error: Throwable ->
+                            .subscribe({ list ->
+                                saveContributionsToDB(list, onRefreshFinish)
+                            },{ error ->
+                                onRefreshFinish()
                                 Timber.e(
                                     "Failed to fetch contributions: %s",
                                     error.message,
                                 )
-                            }
+                            })
                     }?.let {
                         compositeDisposable.add(
                             it,
@@ -83,13 +98,16 @@ class ContributionBoundaryCallback

         /**
          * Saves the contributions the the local DB
+         *
+         * @param onRefreshFinish callback to invoke when successfully saved to DB.
          */
-        private fun saveContributionsToDB(contributions: List<Contribution>) {
+        private fun saveContributionsToDB(contributions: List<Contribution>, onRefreshFinish: () -> Unit) {
             compositeDisposable.add(
                 repository
                     .save(contributions)
                     .subscribeOn(ioThreadScheduler)
                     .subscribe { longs: List<Long?>? ->
+                        onRefreshFinish()
                         repository["last_fetch_timestamp"] = System.currentTimeMillis()
                     },
             )
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java
index 58bd2783dd..0d0a194362 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java
@@ -1,5 +1,6 @@
 package fr.free.nrw.commons.contributions;

+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import fr.free.nrw.commons.BasePresenter;

 /**
@@ -17,5 +18,8 @@ public interface View {
     }

     public interface UserActionListener extends BasePresenter<View> {
+
+        void refreshList(SwipeRefreshLayout swipeRefreshLayout);
+
     }
 }
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
index 509d1eb95b..df65a91cc7 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java
@@ -191,6 +191,15 @@ public View onCreateView(

         initAdapter();

+        // pull down to refresh only enabled for self user.
+        if(Objects.equals(sessionManager.getUserName(), userName)){
+            binding.swipeRefreshLayout.setOnRefreshListener(() -> {
+                contributionsListPresenter.refreshList(binding.swipeRefreshLayout);
+            });
+        } else {
+            binding.swipeRefreshLayout.setEnabled(false);
+        }
+
         return binding.getRoot();
     }

diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java
index 735ff63d43..100c8be031 100644
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java
@@ -8,14 +8,15 @@
 import androidx.paging.DataSource.Factory;
 import androidx.paging.LivePagedListBuilder;
 import androidx.paging.PagedList;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener;
-import fr.free.nrw.commons.di.CommonsApplicationModule;
 import io.reactivex.Scheduler;
 import io.reactivex.disposables.CompositeDisposable;
-import java.util.Arrays;
 import java.util.Collections;
 import javax.inject.Inject;
 import javax.inject.Named;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;

 /**
  * The presenter class for Contributions
@@ -95,4 +96,17 @@ public void onDetachView() {
         contributionBoundaryCallback.dispose();
     }

+    /**
+     * It is used to refresh list.
+     *
+     * @param swipeRefreshLayout used to stop refresh animation when
+     * refresh finishes.
+     */
+    @Override
+    public void refreshList(final SwipeRefreshLayout swipeRefreshLayout) {
+        contributionBoundaryCallback.refreshList(() -> {
+            swipeRefreshLayout.setRefreshing(false);
+            return Unit.INSTANCE;
+        });
+    }
 }
diff --git a/app/src/main/res/layout/fragment_contributions_list.xml b/app/src/main/res/layout/fragment_contributions_list.xml
index b490c35ff5..41121c92aa 100644
--- a/app/src/main/res/layout/fragment_contributions_list.xml
+++ b/app/src/main/res/layout/fragment_contributions_list.xml
@@ -1,125 +1,129 @@
 <?xml version="1.0" encoding="utf-8"?>
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:orientation="vertical"
+
+<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  xmlns:app="http://schemas.android.com/apk/res-auto"
+  xmlns:tools="http://schemas.android.com/tools"
+  android:id="@+id/swipe_refresh_layout"
+  android:layout_width="match_parent"
+  android:layout_height="match_parent">
+
+  <RelativeLayout
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:background="?attr/contributionsListBackground"
-    >
+    android:orientation="vertical">
+

     <TextView
-        android:id="@+id/noContributionsYet"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/no_uploads"
-        android:gravity="center"
-        android:layout_centerInParent="true"
-        android:visibility="gone"
-        android:layout_marginRight="@dimen/tiny_gap"
-        android:layout_marginEnd="@dimen/tiny_gap"
-        />
+      android:id="@+id/noContributionsYet"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:layout_centerInParent="true"
+      android:layout_marginEnd="@dimen/tiny_gap"
+      android:layout_marginRight="@dimen/tiny_gap"
+      android:gravity="center"
+      android:text="@string/no_uploads"
+      android:visibility="gone" />

     <ProgressBar
       android:id="@+id/loadingContributionsProgressBar"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_centerInParent="true"
-      android:visibility="gone"
-      />
+      android:visibility="gone" />

     <RelativeLayout
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:orientation="vertical">

-        <androidx.appcompat.widget.AppCompatTextView
-          android:id="@+id/tv_contributions_of_user"
-          style="@style/MediaDetailTextLabel"
-          tools:text="Contributions of user : Ashish"
-          android:layout_width="match_parent"
-          android:layout_height="wrap_content"
-          android:padding="10dp"
-          tools:visibility="visible"
-          android:visibility="gone" />
-
-        <androidx.recyclerview.widget.RecyclerView
-          android:id="@+id/contributionsList"
-          android:layout_width="match_parent"
-          android:layout_height="match_parent"
-          android:layout_below="@id/tv_contributions_of_user"
-          android:scrollbars="vertical"
-          android:fadeScrollbars="true"
-          android:scrollbarThumbVertical="@color/primaryColor"
-          android:scrollbarSize="@dimen/dimen_6"/>
+      <androidx.appcompat.widget.AppCompatTextView
+        android:id="@+id/tv_contributions_of_user"
+        style="@style/MediaDetailTextLabel"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="10dp"
+        android:visibility="gone"
+        tools:text="Contributions of user : Ashish"
+        tools:visibility="visible" />
+
+      <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/contributionsList"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_below="@id/tv_contributions_of_user"
+        android:fadeScrollbars="true"
+        android:scrollbarSize="@dimen/dimen_6"
+        android:scrollbarThumbVertical="@color/primaryColor"
+        android:scrollbars="vertical" />
     </RelativeLayout>

     <LinearLayout
-        android:id="@+id/fab_layout"
+      android:id="@+id/fab_layout"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:layout_alignParentEnd="true"
+      android:layout_alignParentRight="true"
+      android:layout_alignParentBottom="true"
+      android:layout_marginRight="@dimen/medium_height"
+      android:layout_marginBottom="@dimen/activity_margin_horizontal"
+      android:gravity="center"
+      android:orientation="vertical">
+
+      <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab_camera"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:contentDescription="@string/add_contribution_from_camera"
+        android:tint="@color/button_blue"
+        android:visibility="gone"
+        app:backgroundTint="@color/main_background_light"
+        app:elevation="@dimen/tiny_margin"
+        app:fabSize="mini"
+        app:srcCompat="@drawable/ic_photo_camera_white_24dp"
+        app:useCompatPadding="true" />
+
+      <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab_gallery"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:contentDescription="@string/add_contribution_from_photos"
+        android:tint="@color/button_blue"
+        android:visibility="gone"
+        app:backgroundTint="@color/main_background_light"
+        app:elevation="@dimen/tiny_margin"
+        app:fabSize="mini"
+        app:srcCompat="@drawable/ic_photo_white_24dp"
+        app:useCompatPadding="true" />
+
+      <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab_custom_gallery"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:background="@drawable/commons"
+        android:contentDescription="@string/add_contribution_from_contributions_gallery"
+        android:tint="@color/button_blue"
+        android:visibility="gone"
+        app:backgroundTint="@color/main_background_light"
+        app:elevation="@dimen/tiny_margin"
+        app:fabSize="mini"
+        app:srcCompat="@drawable/ic_custom_image_picker"
+        app:useCompatPadding="true" />
+
+      <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab_plus"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_alignParentBottom="true"
-        android:layout_alignParentEnd="true"
-        android:layout_alignParentRight="true"
-        android:layout_marginRight="@dimen/medium_height"
-        android:layout_marginBottom="@dimen/activity_margin_horizontal"
-        android:orientation="vertical"
-        android:gravity="center"
-        >
-
-        <com.google.android.material.floatingactionbutton.FloatingActionButton
-          android:id="@+id/fab_camera"
-          android:layout_width="wrap_content"
-          android:layout_height="wrap_content"
-          android:contentDescription="@string/add_contribution_from_camera"
-          android:tint="@color/button_blue"
-          android:visibility="gone"
-          app:backgroundTint="@color/main_background_light"
-          app:elevation="@dimen/tiny_margin"
-          app:fabSize="mini"
-          app:srcCompat="@drawable/ic_photo_camera_white_24dp"
-          app:useCompatPadding="true" />
-
-        <com.google.android.material.floatingactionbutton.FloatingActionButton
-          android:id="@+id/fab_gallery"
-          android:layout_width="wrap_content"
-          android:layout_height="wrap_content"
-          android:contentDescription="@string/add_contribution_from_photos"
-          android:tint="@color/button_blue"
-          android:visibility="gone"
-          app:backgroundTint="@color/main_background_light"
-          app:elevation="@dimen/tiny_margin"
-          app:fabSize="mini"
-          app:srcCompat="@drawable/ic_photo_white_24dp"
-          app:useCompatPadding="true" />
-
-        <com.google.android.material.floatingactionbutton.FloatingActionButton
-          android:id="@+id/fab_custom_gallery"
-          android:layout_width="wrap_content"
-          android:layout_height="wrap_content"
-          android:background="@drawable/commons"
-          android:contentDescription="@string/add_contribution_from_contributions_gallery"
-          android:tint="@color/button_blue"
-          android:visibility="gone"
-          app:backgroundTint="@color/main_background_light"
-          app:elevation="@dimen/tiny_margin"
-          app:fabSize="mini"
-          app:srcCompat="@drawable/ic_custom_image_picker"
-          app:useCompatPadding="true" />
-
-        <com.google.android.material.floatingactionbutton.FloatingActionButton
-          android:id="@+id/fab_plus"
-          android:layout_width="wrap_content"
-          android:layout_height="wrap_content"
-          android:contentDescription="@string/add_new_contribution"
-          android:gravity="center_vertical"
-          android:visibility="visible"
-          app:backgroundTint="@color/status_bar_blue"
-          app:elevation="@dimen/tiny_margin"
-          app:srcCompat="@drawable/ic_add_white_24dp"
-          app:useCompatPadding="true" />
+        android:contentDescription="@string/add_new_contribution"
+        android:gravity="center_vertical"
+        android:visibility="visible"
+        app:backgroundTint="@color/status_bar_blue"
+        app:elevation="@dimen/tiny_margin"
+        app:srcCompat="@drawable/ic_add_white_24dp"
+        app:useCompatPadding="true" />

     </LinearLayout>

-</RelativeLayout>
+  </RelativeLayout>

+</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionBoundaryCallbackTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionBoundaryCallbackTest.kt
index 19b36521df..11b5fe9a23 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionBoundaryCallbackTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionBoundaryCallbackTest.kt
@@ -19,6 +19,7 @@ import org.mockito.Mockito.mock
 import org.mockito.Mockito.verifyNoInteractions
 import org.mockito.MockitoAnnotations
 import java.lang.reflect.Method
+import kotlin.reflect.jvm.internal.impl.builtins.functions.FunctionTypeKind

 /**
  * The unit test class for ContributionBoundaryCallbackTest
@@ -99,9 +100,10 @@ class ContributionBoundaryCallbackTest {
         val method: Method =
             ContributionBoundaryCallback::class.java.getDeclaredMethod(
                 "fetchContributions",
+                Function0::class.java
             )
         method.isAccessible = true
-        method.invoke(contributionBoundaryCallback)
+        method.invoke(contributionBoundaryCallback, {})
         verify(repository).save(anyList())
         verify(mediaClient).getMediaListForUser(anyString())
     }
@@ -113,9 +115,10 @@ class ContributionBoundaryCallbackTest {
         val method: Method =
             ContributionBoundaryCallback::class.java.getDeclaredMethod(
                 "fetchContributions",
+                Function0::class.java
             )
         method.isAccessible = true
-        method.invoke(contributionBoundaryCallback)
+        method.invoke(contributionBoundaryCallback, {})
         verifyNoInteractions(repository)
         verify(mediaClient).getMediaListForUser(anyString())
     }


Fix unit tests

**Description (required)**

Fixes #5945 #5921

What changes did you make and why?
1. Move **`createLocale()`** method to companion object in `SettingsFragment`. In Java, that method was previously defined as **static**, making it easier to access from `SettingsFragmentUnitTest`. Also, added the `Mockk` dependency to solve unresolved reference errors.

2. Tests like `LoginActivityUnitTests` and `SessionManagerUnitTests` were failing because the `Mock` library was unable to mock **sealed class`LoginResult`**. So, I mock that class using the `mockk()` method from the **Mockk** library.
**Tests performed (required)**.

3. Fixed some other tests by making minor changes.

Tested prodDebug on Samsung A14 with API level 34.

**Screenshots (for UI changes only)**
None

`Unresolved reference: mockk` when running unit tests
```
> Task :app:compileBetaDebugUnitTestKotlin FAILED
e: app/src/test/kotlin/fr/free/nrw/commons/actions/ThanksClientTest.kt:7:11 Unresolved reference: mockk
e: app/src/test/kotlin/fr/free/nrw/commons/actions/ThanksClientTest.kt:8:11 Unresolved reference: mockk
e: app/src/test/kotlin/fr/free/nrw/commons/actions/ThanksClientTest.kt:42:9 Unresolved reference: mockkObject
e: app/src/test/kotlin/fr/free/nrw/commons/actions/ThanksClientTest.kt:43:9 Unresolved reference: every
e: app/src/test/kotlin/fr/free/nrw/commons/description/DescriptionEditActivityUnitTest.kt:23:11 Unresolved reference: mockk
e: app/src/test/kotlin/fr/free/nrw/commons/description/DescriptionEditActivityUnitTest.kt:24:11 Unresolved reference: mockk
e: app/src/test/kotlin/fr/free/nrw/commons/description/DescriptionEditActivityUnitTest.kt:91:9 Unresolved reference: mockkObject
e: app/src/test/kotlin/fr/free/nrw/commons/description/DescriptionEditActivityUnitTest.kt:92:9 Unresolved reference: every
e: app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt:22:54 Cannot import 'createLocale', functions and properties can be imported only from packages or objects
e: app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt:286:30 Unresolved reference: createLocale
e: app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt:295:30 Unresolved reference: createLocale
e: app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt:304:30 Unresolved reference: createLocale
```

Adding this dependency `testImplementation("io. mockk:mockk:1.13.5")` fixed the problem but got another import issue from SettingsFragment. This is causing a build failure. I am looking into it.
Well, I fixed it. Please assign me so that I can create a PR :)

./gradlew :app:testBetaDebugUnitTest --tests "fr.free.nrw.commons.upload.UploadMediaPresenterTest" --tests "fr.free.nrw.commons.recentlanguages.RecentLanguagesDaoUnitTest" --tests "fr.free.nrw.commons.upload.structure.depictions.DepictedItemTest" --tests "fr.free.nrw.commons.auth.LoginActivityUnitTests" --tests "fr.free.nrw.commons.upload.UploadRepositoryUnitTest" --tests "fr.free.nrw.commons.settings.SettingsFragmentUnitTests" --tests "fr.free.nrw.commons.auth.SessionManagerUnitTests"

diff --git a/app/build.gradle b/app/build.gradle
index 9e6c56c83f..468255d38c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -98,6 +98,7 @@ dependencies {
     testImplementation 'org.mockito:mockito-core:5.6.0'
     testImplementation "org.powermock:powermock-module-junit4:2.0.9"
     testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
+    testImplementation("io.mockk:mockk:1.13.5")

     // Unit testing
     testImplementation 'junit:junit:4.13.2'
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 992c4ed1cf..5571e0ea77 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
@@ -124,7 +124,9 @@ class CategoryClient
                 }.map {
                     it
                         .filter { page ->
-                            !page.categoryInfo().isHidden
+                            // Null check is not redundant because some values could be null
+                            // for mocks when running unit tests
+                            page.categoryInfo()?.isHidden != true
                         }.map {
                             CategoryItem(
                                 it.title().replace(CATEGORY_PREFIX, ""),
diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt
index e97c4f8165..a4a06185bf 100644
--- a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt
+++ b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt
@@ -6,7 +6,6 @@ import android.content.ContentValues
 import android.database.Cursor
 import android.database.sqlite.SQLiteDatabase
 import android.os.RemoteException
-import java.util.ArrayList
 import javax.inject.Inject
 import javax.inject.Named
 import javax.inject.Provider
@@ -163,9 +162,9 @@ class RecentLanguagesDao @Inject constructor(
             COLUMN_CODE
         )

-        private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+        const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"

-        private const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
+        const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
                 "$COLUMN_NAME STRING," +
                 "$COLUMN_CODE STRING PRIMARY KEY" +
                 ");"
diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt
index 0500f4946a..3779532547 100644
--- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt
+++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt
@@ -19,10 +19,10 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
 import io.reactivex.Flowable
 import io.reactivex.Observable
 import io.reactivex.Single
+import timber.log.Timber
 import java.util.Locale
 import javax.inject.Inject
 import javax.inject.Singleton
-import timber.log.Timber

 /**
  * The repository class for UploadActivity
@@ -46,7 +46,7 @@ class UploadRepository @Inject constructor(
      *
      * @return
      */
-    fun buildContributions(): Observable<Contribution> {
+    fun buildContributions(): Observable<Contribution>? {
         return uploadModel.buildContributions()
     }

@@ -150,7 +150,7 @@ class UploadRepository @Inject constructor(
      *
      * @return
      */
-    fun getSelectedLicense(): String {
+    fun getSelectedLicense(): String? {
         return uploadModel.selectedLicense
     }

@@ -173,11 +173,11 @@ class UploadRepository @Inject constructor(
      * @return
      */
     fun preProcessImage(
-        uploadableFile: UploadableFile,
+        uploadableFile: UploadableFile?,
         place: Place?,
-        similarImageInterface: SimilarImageInterface,
+        similarImageInterface: SimilarImageInterface?,
         inAppPictureLocation: LatLng?
-    ): Observable<UploadItem> {
+    ): Observable<UploadItem>? {
         return uploadModel.preProcessImage(
             uploadableFile,
             place,
@@ -193,7 +193,7 @@ class UploadRepository @Inject constructor(
      * @param location Location of the image
      * @return Quality of UploadItem
      */
-    fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int> {
+    fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int>? {
         return uploadModel.getImageQuality(uploadItem, location)
     }

@@ -213,7 +213,7 @@ class UploadRepository @Inject constructor(
      * @param uploadItem UploadItem whose caption is to be checked
      * @return Quality of caption of the UploadItem
      */
-    fun getCaptionQuality(uploadItem: UploadItem): Single<Int> {
+    fun getCaptionQuality(uploadItem: UploadItem): Single<Int>? {
         return uploadModel.getCaptionQuality(uploadItem)
     }

diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt
index 53f6b28fe2..b55ac60099 100644
--- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt
+++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt
@@ -471,18 +471,20 @@ class SettingsFragment : PreferenceFragmentCompat() {
         editor.apply()
     }

-    /**
-     * Create Locale based on different types of language codes
-     * @param languageCode
-     * @return Locale and throws error for invalid language codes
-     */
-    fun createLocale(languageCode: String): Locale {
-        val parts = languageCode.split("-")
-        return when (parts.size) {
-            1 -> Locale(parts[0])
-            2 -> Locale(parts[0], parts[1])
-            3 -> Locale(parts[0], parts[1], parts[2])
-            else -> throw IllegalArgumentException("Invalid language code: $languageCode")
+    companion object {
+        /**
+         * Create Locale based on different types of language codes
+         * @param languageCode
+         * @return Locale and throws error for invalid language codes
+         */
+        fun createLocale(languageCode: String): Locale {
+            val parts = languageCode.split("-")
+            return when (parts.size) {
+                1 -> Locale(parts[0])
+                2 -> Locale(parts[0], parts[1])
+                3 -> Locale(parts[0], parts[1], parts[2])
+                else -> throw IllegalArgumentException("Invalid language code: $languageCode")
+            }
         }
     }

diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt
index 2e0efc6901..8393dc6524 100644
--- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt
+++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt
@@ -16,7 +16,7 @@ class ImageUtilsWrapper @Inject constructor() {

     fun checkImageGeolocationIsDifferent(
         geolocationOfFileString: String,
-        latLng: LatLng
+        latLng: LatLng?
     ): Single<Int> {
         return Single.fromCallable {
             ImageUtils.checkImageGeolocationIsDifferent(geolocationOfFileString, latLng)
diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt
index 9e46525da5..93cdabbfc0 100644
--- a/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt
+++ b/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt
@@ -1,9 +1,5 @@
 package fr.free.nrw.commons.utils

-import org.apache.commons.lang3.StringUtils
-
-import java.util.ArrayList
-
 object MediaDataExtractorUtil {

     /**
@@ -13,8 +9,8 @@ object MediaDataExtractorUtil {
      * @return
      */
     @JvmStatic
-    fun extractCategoriesFromList(source: String): List<String> {
-        if (source.isBlank()) {
+    fun extractCategoriesFromList(source: String?): List<String> {
+        if (source.isNullOrBlank()) {
             return emptyList()
         }
         val cats = source.split("|")

diff --git a/app/src/test/kotlin/fr/free/nrw/commons/auth/LoginActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/auth/LoginActivityUnitTests.kt
index b50c820a0d..162f505848 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/auth/LoginActivityUnitTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/auth/LoginActivityUnitTests.kt
@@ -16,6 +16,7 @@ import fr.free.nrw.commons.TestCommonsApplication
 import fr.free.nrw.commons.auth.login.LoginResult
 import fr.free.nrw.commons.createTestClient
 import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.mockk.mockk
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
@@ -66,11 +67,11 @@ class LoginActivityUnitTests {
     @Mock
     private lateinit var account: Account

-    @Mock
     private lateinit var loginResult: LoginResult

     @Before
     fun setUp() {
+        loginResult = mockk()
         MockitoAnnotations.openMocks(this)
         OkHttpConnectionFactory.CLIENT = createTestClient()
         activity = Robolectric.buildActivity(LoginActivity::class.java).create().get()
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/auth/SessionManagerUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/auth/SessionManagerUnitTests.kt
index 7b7c260e86..4e5c78f3e5 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/auth/SessionManagerUnitTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/auth/SessionManagerUnitTests.kt
@@ -7,13 +7,14 @@ import androidx.test.core.app.ApplicationProvider
 import fr.free.nrw.commons.TestCommonsApplication
 import fr.free.nrw.commons.auth.login.LoginResult
 import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.mockk.every
+import io.mockk.mockk
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
 import org.mockito.MockitoAnnotations
-import org.powermock.api.mockito.PowerMockito.`when`
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.Shadows.shadowOf
 import org.robolectric.annotation.Config
@@ -33,7 +34,6 @@ class SessionManagerUnitTests {
     @Mock
     private lateinit var defaultKvStore: JsonKvStore

-    @Mock
     private lateinit var loginResult: LoginResult

     @Mock
@@ -41,6 +41,7 @@ class SessionManagerUnitTests {

     @Before
     fun setUp() {
+        loginResult = mockk()
         MockitoAnnotations.openMocks(this)
         accountManager = AccountManager.get(ApplicationProvider.getApplicationContext())
         shadowOf(accountManager).addAccount(account)
@@ -68,8 +69,8 @@ class SessionManagerUnitTests {
     @Test
     @Throws(Exception::class)
     fun testUpdateAccount() {
-        `when`(loginResult.userName).thenReturn("username")
-        `when`(loginResult.password).thenReturn("password")
+        every { loginResult.userName } returns "username"
+        every { loginResult.password } returns "password"
         val method: Method =
             SessionManager::class.java.getDeclaredMethod(
                 "updateAccount",
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/recentlanguages/RecentLanguagesDaoUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/recentlanguages/RecentLanguagesDaoUnitTest.kt
index 087640a44c..e0f4587f46 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/recentlanguages/RecentLanguagesDaoUnitTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/recentlanguages/RecentLanguagesDaoUnitTest.kt
@@ -85,7 +85,7 @@ class RecentLanguagesDaoUnitTest {
         whenever(client.query(any(), any(), anyOrNull(), any(), anyOrNull()))
             .thenReturn(createCursor(14))

-        val result = testObject.recentLanguages
+        val result = testObject.getRecentLanguages()

         Assert.assertEquals(14, (result.size))
     }
@@ -95,20 +95,20 @@ class RecentLanguagesDaoUnitTest {
         whenever(client.query(any(), any(), anyOrNull(), any(), anyOrNull())).thenThrow(
             RemoteException(""),
         )
-        testObject.recentLanguages
+        testObject.getRecentLanguages()
     }

     @Test
     fun getGetRecentLanguagesReturnsEmptyList_emptyCursor() {
         whenever(client.query(any(), any(), anyOrNull(), any(), anyOrNull()))
             .thenReturn(createCursor(0))
-        Assert.assertTrue(testObject.recentLanguages.isEmpty())
+        Assert.assertTrue(testObject.getRecentLanguages().isEmpty())
     }

     @Test
     fun getGetRecentLanguagesReturnsEmptyList_nullCursor() {
         whenever(client.query(any(), any(), anyOrNull(), any(), anyOrNull())).thenReturn(null)
-        Assert.assertTrue(testObject.recentLanguages.isEmpty())
+        Assert.assertTrue(testObject.getRecentLanguages().isEmpty())
     }

     @Test
@@ -117,7 +117,7 @@ class RecentLanguagesDaoUnitTest {
         whenever(client.query(any(), any(), anyOrNull(), any(), anyOrNull())).thenReturn(mockCursor)
         whenever(mockCursor.moveToFirst()).thenReturn(false)

-        testObject.recentLanguages
+        testObject.getRecentLanguages()

         verify(mockCursor).close()
     }
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt
index 6c9ee9d039..5a6d27e1bb 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt
@@ -19,7 +19,7 @@ import fr.free.nrw.commons.TestCommonsApplication
 import fr.free.nrw.commons.recentlanguages.Language
 import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter
 import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
-import fr.free.nrw.commons.settings.SettingsFragment.createLocale
+import fr.free.nrw.commons.settings.SettingsFragment.Companion.createLocale
 import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Before
@@ -156,14 +156,14 @@ class SettingsFragmentUnitTests {
             )
         method.isAccessible = true
         method.invoke(fragment, "appUiDefaultLanguagePref")
-        verify(recentLanguagesDao, times(1)).recentLanguages
+        verify(recentLanguagesDao, times(1)).getRecentLanguages()
     }

     @Test
     @Throws(Exception::class)
     fun `Test prepareAppLanguages when recently used languages is not empty`() {
         Shadows.shadowOf(Looper.getMainLooper()).idle()
-        whenever(recentLanguagesDao.recentLanguages)
+        whenever(recentLanguagesDao.getRecentLanguages())
             .thenReturn(
                 mutableListOf(
                     Language("English", "en"),
@@ -181,7 +181,7 @@ class SettingsFragmentUnitTests {
             )
         method.isAccessible = true
         method.invoke(fragment, "appUiDefaultLanguagePref")
-        verify(recentLanguagesDao, times(2)).recentLanguages
+        verify(recentLanguagesDao, times(2)).getRecentLanguages()
     }

     @Test
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt
index bf27280aae..47c4d0ae56 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt
@@ -131,7 +131,7 @@ class UploadMediaPresenterTest {
      */
     @Test
     fun getImageQualityTest() {
-        whenever(repository.uploads).thenReturn(listOf(uploadItem))
+        whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
         whenever(repository.getImageQuality(uploadItem, location))
             .thenReturn(testSingleImageResult)
         whenever(uploadItem.imageQuality).thenReturn(0)
@@ -149,7 +149,7 @@ class UploadMediaPresenterTest {
      */
     @Test
     fun `get ImageQuality Test while coordinates equals to null`() {
-        whenever(repository.uploads).thenReturn(listOf(uploadItem))
+        whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
         whenever(repository.getImageQuality(uploadItem, location))
             .thenReturn(testSingleImageResult)
         whenever(uploadItem.imageQuality).thenReturn(0)
@@ -225,7 +225,7 @@ class UploadMediaPresenterTest {
      */
     @Test
     fun fetchImageAndTitleTest() {
-        whenever(repository.uploads).thenReturn(listOf(uploadItem))
+        whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
         whenever(repository.getUploadItem(ArgumentMatchers.anyInt()))
             .thenReturn(uploadItem)
         whenever(uploadItem.uploadMediaDetails).thenReturn(listOf())
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt
index 233b0de32c..ac01d237fe 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt
@@ -15,6 +15,7 @@ import fr.free.nrw.commons.repository.UploadRepository
 import fr.free.nrw.commons.upload.structure.depictions.DepictModel
 import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
 import io.reactivex.Completable
+import io.reactivex.Observable
 import io.reactivex.Single
 import org.junit.Before
 import org.junit.Test
@@ -196,7 +197,7 @@ class UploadRepositoryUnitTest {
     fun testGetCaptionQuality() {
         assertEquals(
             repository.getCaptionQuality(uploadItem),
-            uploadModel.getCaptionQuality(uploadItem),
+            uploadModel.getCaptionQuality(uploadItem)
         )
     }

@@ -386,7 +387,8 @@ class UploadRepositoryUnitTest {
     fun testGetCategories() {
         assertEquals(
             repository.getCategories(listOf("Test")),
-            categoriesModel.getCategoriesByName(mutableListOf("Test")),
+            categoriesModel.getCategoriesByName(mutableListOf("Test"))
+                ?: Observable.empty<List<CategoryItem>>()
         )
     }
 }
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 892d501fd2..e0d339eee8 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
@@ -1,10 +1,10 @@
 package fr.free.nrw.commons.upload.structure.depictions

-import com.nhaarman.mockitokotlin2.mock
 import depictedItem
 import entity
 import entityId
 import fr.free.nrw.commons.wikidata.WikidataProperties
+import io.mockk.mockk
 import org.junit.Assert
 import org.junit.Test
 import place
@@ -53,7 +53,7 @@ class DepictedItemTest {
                 entity(
                     statements =
                         mapOf(
-                            WikidataProperties.IMAGE.propertyName to listOf(statement(snak(dataValue = mock()))),
+                            WikidataProperties.IMAGE.propertyName to listOf(statement(snak(dataValue = mockk()))),
                         ),
                 ),
             ).imageUrl,