Repository: commons-app/apps-android-commons (language: kotlin)

Issue:
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.


Discussion hints:
(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 :)

Pull request: 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



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

Fix patch:
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>


Test patch:
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())
     }