diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
new file mode 100644
index 000000000..dcbba0597
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -0,0 +1,85 @@
+name: "\U0001F41E Bug report"
+description: Create a report to help us improve.
+title: "[Bug]: "
+type: Bug  # Retained to categorize the issue as per organization-level type
+body:
+  - type: markdown
+    attributes:
+      value: |
+        - Before creating an issue, please search the existing issues to see if a similar one has already been created.
+        - You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter.
+  - type: textarea
+    attributes:
+      label: Summary
+      description: Summarize your issue (what goes wrong, what did you expect to happen)
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Steps to reproduce
+      description: How can we reproduce the issue?
+      placeholder: |
+        1. Have the app open..
+        2. Go to..
+        3. Click on..
+        4. Observe..
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Expected behaviour
+      placeholder: A menu should open..
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Actual behaviour
+      placeholder: The app closes unexpectedly..
+    validations:
+      required: true
+  - type: markdown
+    attributes:
+      value: |
+        # Device information
+  - type: input
+    attributes:
+      label: Device name
+      description: What make and model device did you encounter this on?
+      placeholder: Samsung J7
+    validations:
+      required: false
+  - type: input
+    attributes:
+      label: Android version
+      description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ?
+      placeholder: Android 10
+    validations:
+      required: true
+  - type: input
+    attributes:
+      label: Commons app version
+      description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`).
+      placeholder: 3.1.1
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Device logs
+      description: Add logcat files here (if possible). Need help? See "[Getting app logs from Android Studio](https://commons-app.github.io/docs.html#getting-app-logs-from-android-studio)".
+    validations:
+      required: false
+  - type: textarea
+    attributes:
+      label: Screenshots
+      description: Add screenshots related to the issue (if available). Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher.
+    validations:
+      required: false
+  - type: dropdown
+    attributes:
+      label: Would you like to work on the issue?
+      description: Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them.
+      options:
+        - "Yes"
+        - Prefer not
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 000000000..5ac210240
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,30 @@
+name: "⭐️ Feature request"
+description: Suggest an idea for this project
+labels: ["enhancement"]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        - Please do your best to search for duplicate issues before filing a new issue so we can keep our issue board clean.
+        - Every issue should have exactly one feature request described in it. Please do not file feedback list tickets as it is difficult to parse them and address their individual points.
+        - Feature Requests are better when they’re open-ended instead of demanding a specific solution e.g: “I want an easier way to do X” instead of “add Y”.
+  - type: textarea
+    attributes:
+      label: What is the user problem or growth opportunity you want to see solved?
+    validations:
+      required: false
+  - type: textarea
+    attributes:
+      label: How do you know that this problem exists today? Why is this important?
+    validations:
+      required: false
+  - type: textarea
+    attributes:
+      label: Who will benefit from it?
+    validations:
+      required: false
+  - type: textarea
+    attributes:
+      label: Anything else you would like to add?
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml
new file mode 100644
index 000000000..febde65f6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feedback.yml
@@ -0,0 +1,46 @@
+name: "\U0001F4AC Feedback"
+description: Share your feedback about the app
+labels: ["feedback"]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        - Before creating an issue, please search the existing issues to see if a similar one has already been created.
+        - You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter.
+  - type: textarea
+    attributes:
+      label: Feedback
+      description: Share your feedback about the app.
+    validations:
+      required: true
+  - type: input
+    attributes:
+      label: Wiki username
+      placeholder: Jimbo Wales
+    validations:
+      required: false
+  - type: markdown
+    attributes:
+      value: |
+        # Device information
+  - type: input
+    attributes:
+      label: Device name
+      description: What make and model device did you encounter this on?
+      placeholder: Samsung J7
+    validations:
+      required: false
+  - type: input
+    attributes:
+      label: Android version
+      description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ?
+      placeholder: Android 10
+    validations:
+      required: false
+  - type: input
+    attributes:
+      label: Commons app version
+      description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`).
+      placeholder: 3.1.1
+    validations:
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/need-help.yml b/.github/ISSUE_TEMPLATE/need-help.yml
new file mode 100644
index 000000000..64ddabda6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/need-help.yml
@@ -0,0 +1,13 @@
+name: "✋🏻 Need help"
+description: Describe the situation which you need help with.
+labels: ["help needed"]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        - Describe the situation which you need help with with as much information as possible.
+  - type: textarea
+    attributes:
+      label: Description
+    validations:
+      required: true
diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
similarity index 100%
rename from PULL_REQUEST_TEMPLATE.md
rename to .github/PULL_REQUEST_TEMPLATE.md
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 000000000..bc8b03c9e
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,114 @@
+name: Android CI
+
+on: [push, pull_request, workflow_dispatch]
+
+concurrency:
+  group: build-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  build:
+    name: Run tests and generate APK
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Set up JDK
+        uses: actions/setup-java@v4
+        with:
+          distribution: 'temurin'
+          java-version: '17'
+
+      - name: Cache packages
+        id: cache-packages
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.gradle/caches
+            ~/.gradle/wrapper
+          key: gradle-packages-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }}
+          restore-keys: gradle-packages-${{ runner.os }}
+
+      - name: Access test login credentials
+        run: |
+          echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties
+          echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties
+
+      - name: AVD cache
+        if: github.event_name != 'pull_request'
+        uses: actions/cache@v4
+        id: avd-cache
+        with:
+          path: |
+            ~/.android/avd/*
+            ~/.android/adb*
+          key: avd-tablet-api-24
+
+      - name: Create AVD and generate snapshot for caching
+        if: steps.avd-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request'
+        uses: reactivecircus/android-emulator-runner@v2
+        with:
+          api-level: 24
+          force-avd-creation: false
+          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+          disable-animations: true
+          script: echo "Generated AVD snapshot for caching."
+
+      - name: Run Instrumentation tests
+        if: github.event_name != 'pull_request'
+        uses: reactivecircus/android-emulator-runner@v2
+        with:
+          api-level: 24
+          force-avd-creation: false
+          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+          disable-animations: true
+          profile: Nexus 10
+          script: |
+            adb shell content insert --uri content://settings/system --bind name:s:accelerometer_rotation --bind value:i:0
+            adb shell content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:0
+            adb emu geo fix 37.422131 -122.084801
+            ./gradlew connectedBetaDebugAndroidTest --stacktrace
+
+      - name: Run Unit tests with unified coverage
+        if: github.event_name != 'pull_request'
+        run: ./gradlew -Pcoverage testBetaDebugUnitTestUnifiedCoverage --stacktrace
+
+      - name: Run Unit tests without unified coverage
+        if: github.event_name == 'pull_request'
+        run: ./gradlew -Pcoverage testBetaDebugUnitTestCoverage --stacktrace
+
+      - name: Upload Test Report to Codecov
+        if: github.event_name != 'pull_request'
+        run: |
+          curl -Os https://uploader.codecov.io/latest/linux/codecov
+          chmod +x codecov
+          ./codecov -f "app/build/reports/jacoco/testBetaDebugUnitTestUnifiedCoverage/testBetaDebugUnitTestUnifiedCoverage.xml" -Z
+
+      - name: Generate betaDebug APK
+        run: bash ./gradlew assembleBetaDebug --stacktrace
+
+      - name: Upload betaDebug APK
+        uses: actions/upload-artifact@v4
+        with:
+          name: betaDebugAPK
+          path: app/build/outputs/apk/beta/debug/app-*.apk
+
+      - name: Generate prodDebug APK
+        run: bash ./gradlew assembleProdDebug --stacktrace
+
+      - name: Upload prodDebug APK
+        uses: actions/upload-artifact@v4
+        with:
+          name: prodDebugAPK
+          path: app/build/outputs/apk/prod/debug/app-*.apk
+
+      - name: Create and PR number artifact
+        run: |
+          echo "{\"pr_number\": ${{ github.event.pull_request.number || 'null' }}}" > pr_number.json
+          
+      - name: Upload PR number artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: pr_number
+          path: ./pr_number.json
diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml
new file mode 100644
index 000000000..8e1a26e15
--- /dev/null
+++ b/.github/workflows/build-beta.yml
@@ -0,0 +1,41 @@
+name: Build beta only
+
+on: [workflow_dispatch]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: set up JDK 17
+      uses: actions/setup-java@v4
+      with:
+        java-version: '17'
+        distribution: 'temurin'
+        cache: gradle
+
+    - name: Access test login credentials
+      run: |
+        echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties
+        echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties
+
+    - name: Grant execute permission for gradlew
+      run: chmod +x gradlew
+
+    - name: Set env
+      run: echo "COMMIT_SHA=$(git log -n 1 --format='%h')" >> $GITHUB_ENV
+
+    - name: Generate betaDebug APK
+      run: ./gradlew assembleBetaDebug --stacktrace
+
+    - name: Rename betaDebug APK
+      run: mv app/build/outputs/apk/beta/debug/app-*.apk app/build/outputs/apk/beta/debug/apps-android-commons-betaDebug-$COMMIT_SHA.apk
+
+    - name: Upload betaDebug APK
+      uses: actions/upload-artifact@v4
+      with:
+        name: apps-android-commons-betaDebugAPK-${{ env.COMMIT_SHA }}
+        path: app/build/outputs/apk/beta/debug/*.apk
+        retention-days: 30
diff --git a/.github/workflows/comment_artifacts_on_PR.yml b/.github/workflows/comment_artifacts_on_PR.yml
new file mode 100644
index 000000000..ee4ae7c46
--- /dev/null
+++ b/.github/workflows/comment_artifacts_on_PR.yml
@@ -0,0 +1,96 @@
+name: Comment Artifacts on PR
+
+on:
+  workflow_run:
+    workflows: [ "Android CI" ]
+    types: [ completed ]
+
+permissions:
+  pull-requests: write
+  contents: read
+
+concurrency:
+  group: comment-${{ github.event.workflow_run.id }}
+  cancel-in-progress: true
+
+jobs:
+  comment:
+    runs-on: ubuntu-latest
+    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
+    steps:
+      - name: Download and process artifacts
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const fs = require('fs');
+            const runId = context.payload.workflow_run.id;
+
+            const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: runId,
+            });
+
+            const prNumberArtifact = allArtifacts.data.artifacts.find(artifact => artifact.name === "pr_number");
+            if (!prNumberArtifact) {
+              console.log("pr_number artifact not found.");
+              return;
+            }
+
+            const download = await github.rest.actions.downloadArtifact({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              artifact_id: prNumberArtifact.id,
+              archive_format: 'zip',
+            });
+
+            fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/pr_number.zip`, Buffer.from(download.data));
+            const { execSync } = require('child_process');
+            execSync('unzip -q pr_number.zip -d ./pr_number/');
+            fs.unlinkSync('pr_number.zip');
+
+            const prData = JSON.parse(fs.readFileSync('./pr_number/pr_number.json', 'utf8'));
+            const prNumber = prData.pr_number;
+
+            if (!prNumber || prNumber === 'null') {
+              console.log("No valid PR number found in pr_number.json. Skipping.");
+              return;
+            }
+
+            const artifactsToLink = allArtifacts.data.artifacts.filter(artifact => artifact.name !== "pr_number");
+            if (artifactsToLink.length === 0) {
+              console.log("No artifacts to link found.");
+              return;
+            }
+
+            const comments = await github.rest.issues.listComments({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: Number(prNumber),
+            });
+            
+            const oldComments = comments.data.filter(comment => 
+              comment.body.startsWith("✅ Generated APK variants!")
+            );
+            for (const comment of oldComments) {
+              await github.rest.issues.deleteComment({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                comment_id: comment.id,
+              });
+              console.log(`Deleted old comment ID: ${comment.id}`);
+            };
+            
+            const commentBody = `✅ Generated APK variants!\n` + 
+            artifactsToLink.map(artifact => {
+                const artifactUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
+                return `- 🤖 [Download ${artifact.name}](${artifactUrl})`;
+            }).join('\n');
+
+            await github.rest.issues.createComment({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: Number(prNumber),
+              body: commentBody
+            });
diff --git a/.gitignore b/.gitignore
index 63a882d02..7fa4767a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ app/src/main/gen/*
 
 # IDEA/Android Studio Ignore exceptions
 !/.idea/codeStyles/
+!/.idea/inspectionProfiles/
 
 # Gradle
 .gradle
@@ -42,3 +43,8 @@ app/src/main/jniLibs
 #https://docs.opencv.org/3.3.0/
 /libraries/opencv/javadoc/
 captures/*
+
+# Test and other output
+app/jacoco.exec
+app/CommonsContributions
+app/.*
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index ccc2da979..ea0cb3b07 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -7,13 +7,6 @@
         
       
     
-    
-      
-    
     
       
     
@@ -23,6 +16,7 @@
       
       
         
+          
           
           
           
@@ -46,21 +40,18 @@
       
       
     
-    
-      
-        
-        
-      
-    
     
       
     
     
       
     
-    
-      
-    
+    
+      
+        
+        
+      
+    
     
       
         
@@ -118,7 +109,6 @@
       
       
       
-        
         
         
       
@@ -248,6 +238,28 @@
               
             
           
+          
+            
+              
+                
+                  .*:name
+                  
+                  http://schemas.android.com/apk/res/android
+                
+              
+            
+          
+          
+            
+              
+                
+                  name
+                  
+                  ^$
+                
+              
+            
+          
           
             
               
@@ -275,211 +287,12 @@
             
               
                 
-                  .*:.*Style
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-              BY_NAME
-            
-          
-          
-            
-              
-                
-                  .*:layout_width
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_height
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_weight
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_margin
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginTop
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginBottom
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginStart
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginEnd
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginLeft
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_marginRight
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:layout_.*
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-              BY_NAME
-            
-          
-          
-            
-              
-                
-                  .*:padding
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingTop
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingBottom
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingStart
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingEnd
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingLeft
-                  
-                  http://schemas.android.com/apk/res/android
-                
-              
-            
-          
-          
-            
-              
-                
-                  .*:paddingRight
+                  .*
                   
                   http://schemas.android.com/apk/res/android
                 
               
+              ANDROID_ATTRIBUTE_ORDER
             
           
           
@@ -487,39 +300,7 @@
               
                 
                   .*
-                  http://schemas.android.com/apk/res/android
-                
-              
-              BY_NAME
-            
-          
-          
-            
-              
-                
-                  .*
-                  http://schemas.android.com/apk/res-auto
-                
-              
-              BY_NAME
-            
-          
-          
-            
-              
-                
-                  .*
-                  http://schemas.android.com/tools
-                
-              
-              BY_NAME
-            
-          
-          
-            
-              
-                
-                  .*
+                  
                   .*
                 
               
@@ -535,9 +316,7 @@
     
       
       
-        
         
-        
       
     
   
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000..265d8a96d
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,90 @@
+
+  
+    
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+      
+    
+    
+    
+      
+      
+    
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+      
+    
+    
+    
+    
+      
+      
+    
+    
+    
+    
+      
+      
+      
+    
+    
+    
+    
+      
+    
+    
+    
+    
+  
+
\ No newline at end of file
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 000000000..b140127f9
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,5 @@
+# See: https://git-scm.com/docs/git-shortlog#_mapping_authors
+#
+Brooke Vibber 
+Brooke Vibber  
+Brooke Vibber  
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index bdf6f0634..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-language: android
-addons:
-  apt:
-    packages:
-    - w3m
-env:
-  global:
-  - ANDROID_TARGET=android-22
-  - ANDROID_ABI=armeabi-v7a
-  - ADB_INSTALL_TIMEOUT=12
-  - secure: okdkna5DaH/2Fay9vI6Enrx7u9UwRm4/IJXvcaWJcvjF3JTsLQr0r+dlMT2X5E1GsNk4WcoGcfZJcVonULkaW4S96B43g3EyevWbLFWjii0cMUO00OshToKyboSvNUf+d5B6rghrbnxTIBNel2ZBFj8MXHdtz6Az20q8VywqPeBZupo7olyKKS1nYdvoo7ypNScVjDGEjEPonWplztYlSDT1w81Vww4kF9oiOPEzDOPw1lOiD8FTyKLXhK0WYlnc3cnyFjZwVMlKcomnFYPfe/J2zO6OP/XInxYSXRkZ6wiOC5gMPYAYanUAuzm91vsTBQMk6jMCglSM9Nl6dPusGgEqOyTwLVALlgvS3km9HNVsHuVJhU+bmJ6scFBWrAOhbsV2ioSEsQ8NgU0Zv1SC0wN9ZruF4ae03Re+k+eHgwA3taZXrT2pvkkSmfRex6oFZReypcPGFQYiHo31NsO39WPRYYxr4edYisVXw75x/BJyOcUULhG1YmwHYYeXOzbNp0Sf9ADtUDi0oip/BO2tkLxbE+z1GJSmC83fX2YpoK+IwDHNm+4w8OJAJBvdxA3Q4HrJBAbd8jnQYP+sBBaki8t5WuwJmfOucx0vgKJ7pzqRY/MOUVe/dACnjLgFDLuS7MMqr6xU/oMM6/rrt4209tL+GQbn/R98UKtmMRRq1hY=
-jdk:
-- oraclejdk8
-android:
-  components:
-  - tools
-  - platform-tools
-  - build-tools-28.0.3
-  - extra-google-m2repository
-  - extra-android-m2repository
-  - android-22
-  - android-28
-  licenses:
-  - android-sdk-license-.+
-script:
-- "./gradlew -Pcoverage lintBetaDebug pmd checkstyle jacocoTestBetaDebugUnitTestReport"
-- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
-  mkdir -p app/src/prodRelease/play/release-notes/en-US;
-  fi
-- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
-  git log --pretty=format:"%s" HEAD^..HEAD > app/src/prodRelease/play/release-notes/en-US/default.txt;
-  fi
-- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
-  ./gradlew publishProdReleaseApk;
-  fi
-after_success:
-- bash <(curl -s https://codecov.io/bash)
-after_failure:
-- echo '*** Debug Unit Test Results ***'
-- w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/tests/*/classes/*Test.html
-- echo '*** Connected Test Results ***'
-- w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/androidTests/connected/flavors/*/*Test.html
-before_cache:
-- rm -f  $HOME/.gradle/caches/modules-2/modules-2.lock
-- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
-cache:
-  directories:
-  - "$HOME/.gradle/caches/"
-  - "$HOME/.gradle/wrapper/"
-before_install:
-- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
-  openssl aes-256-cbc -K $encrypted_7b5c925cc32c_key -iv $encrypted_7b5c925cc32c_iv -in nr-commons.keystore.enc -out nr-commons.keystore -d;
-  fi
-- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
-  openssl aes-256-cbc -K $encrypted_38ac1a5053f6_key -iv $encrypted_38ac1a5053f6_iv -in play.p12.enc -out play.p12 -d;
-  fi
-notifications:
-  webhooks:
-    - https://wiki-commons.zulipchat.com/api/v1/external/travis?api_key=kn4a8YKNqHCBYp7EW2k463txMj35vReq&stream=travis-ci
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba6016e64..575aa6a32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,368 @@
 # Wikimedia Commons for Android
 
-## v2.13.2
-- Fixed issue with invisible pins on Nearby map being targetable
-- Fixed logout bug
+## v6.0.2
+
+### What's changed
+* Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard
+* Links in the "File usages" list are now clickable and will take you to the correct page.
+* Titles for file usages are now clearer and easier to understand
+* Bug fixes and stability improvements
+
+## v6.0.1
+
+### What's changed
+* The app now supports Android 15 with an improved user interface
+* Enhanced Nearby with robust and more reliable labels
+* Bug fixes and stability improvements
+
+## v5.6.1
+
+### What's changed
+* The app no longer uploads images to Wikidata if one exists already for a given item
+* File usage displays correctly now
+* No more infinite circular progress bar on nominating an image for deletion
+* Enhanced location updates while using GPS
+* Author/uploader names are now available in Media Details for Commons licensing compliance
+* Improved usage of popups in Nearby
+* Bug fixes and stability improvements 
+
+## v5.5.0
+
+### What's changed
+* Explore images will now be shown based on the map location and not at your current location
+* Enhanced Wikidata feedback message
+* Green labels in Explore map will no longer be hidden by other pins thumbnails
+* Upload wizard's language drop-down now reflects the language used in the pin label
+* Users can now pick only one image at a time while using the custom selector
+* Bug fixes and stability improvements 
+
+## v5.4.1
+
+### What's changed
+* Custom picker now detects images that are already available on Commons
+* Improve credit line in image list
+* Show place cards with loaded names only in the Nearby list
+* Fix the error that occurs while loading images in Explore
+
+## v5.3.0
+
+### What's changed
+* Enable EmailAuth support
+* Explore map images no longer show "Unknown"
+* Fix crash when removing last two images of multiupload
+* Mark ❌ for closed locations (P3999) in Nearby
+* Fix two pin labels staying visible at the same time in Explore map
+* Refactoring and minor UI improvements
+
+## v5.2.0
+
+v5.2.0 boasts several new functionalities like:
+
+* A new refresh button lets you quickly reload the Nearby map
+* Bookmarks now support categories
+* Improved feedback and consistency in the user interface
+* Bug fixes and performance improvements
+
+### What's changed
+* Implement "Refresh" button to clear the cache and reload the Nearby map.
+* `CommonsApplication` migrate to kotlin & some lint fixes.
+* Revert back to MainScope for database and UI updates and make database operations thread safe.
+* Hide edit options for logged-out users in Explore screen.
+* Introduced a button to delete the current folder in custom selector.
+* Improve Unique File Name Search.
+* Migration of several modules from Java to Kotlin.
+* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins.
+* Bug fixes and enhancement of Achievements screen.
+* Show where file is being used on Commons and other wikis.
+* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices.
+* Make dialogs modal and always show the upload icon.
+* Fix unintentional deletion of subfolders and non-images by custom selector.
+* Bookmark categories.
+* Add pull down to refresh in the Contributions screen.
+* Fix race condition and lag when loading pin details, faster overlay management.
+* Show cached pins in Nearby even when internet is unavailable
+
+ Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0).
+
+
+## v5.1.2
+
+### What's changed
+
+* Fix the broken category search in the explore screen
+
+## v5.1.1
+
+### What's changed
+
+* Use Android's new EXIF interface to mitigate security issues in old
+  EXIF interface.
+* Make the icon that helps view the upload queue always visible as it ensures
+  that the queue accessible at all times.
+
+## v5.1.0
+
+### What's Changed
+
+* Enhanced **upload queue management** in the Commons app for smoother, sequential
+  processing, clearer progress tracking, prevention of stuck or duplicate
+  uploads. As part of this improvement, the "Limited Connection mode" has been
+  removed.
+* Added an option in "Nearby" feature enabling users to **provide feedback on
+  Wikidata items**. Users can report if an item doesn’t exist, is at a different
+  location, or has other issues, with submissions tagged for easy tracking and
+  updates.
+* Improved the "Nearby" feature by splitting the query into two parts for faster
+  loading and **better performance, especially in areas with dense amount of
+  places**. This update also resolves issues with pins overlapping place names.
+* Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to
+  the app such as adding **"Partial Access" support**. Also includes some minor
+  refactoring, and replacement of deprecated circular progress bars.
+* Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs
+  appeared blank** in the Category Details screen. Resolved by optimizing view
+  binding handling in the parent fragments.
+* Fixed an issue where editing depictions removed all other structured data from
+  images. Now, **only depictions are updated, preserving other associated data**.
+* Fixed **map centering** in the image upload flow to **use GPS EXIF tag location**
+  from pictures and ensured "Show in map app" accurately reflects this location.
+* Fixed navigation **after uploading via Nearby by directing users to the Uploads
+  activity** instead of returning to Nearby, preventing confusion about needing to
+  upload again.
+
+### Bug fixes and various changes
+
+* Improved the "Nearby" feature to fetch labels based on the user's preferred
+  language instead of defaulting to English.
+* Added a legend to the "Nearby" feature indicating pin statuses: red for items
+  without pictures, green for those with pictures, and grey for items being
+  checked. A floating action button now allows users to toggle the legend's
+  visibility.
+* Fixed an issue where the "Nominate for deletion" option is shown to logged out
+  users, preventing app errors and crashes.
+* Updated the regex pattern that filters categories with an year in it to also
+  filter the 2020s.
+* Fix an issue where past depictions were not shown as suggestions, despite
+  being saved correctly.
+* Fixed an issue in custom image picker where exiting the media preview showed
+  only the first image and cleared selections. Now, previously selected images
+  are restored correctly after exiting the preview. This was contributed.
+* Fixed an issue in custom image picker where scrolling behavior did not
+  maintain position after exiting fullscreen preview, ensuring users remain at
+  the same point in their image roll unless actioned images are filtered. This
+  was contributed.
+* Fixed Nearby map not showing new pins on map move by removing the 2000m scroll
+  threshold and adding an 800ms debounce for smoother pin updates when the map
+  is moved. Queued searches are now canceled on fragment destruction.
+* Revised author information retrieval to emphasize the custom author name from
+  the metadata instead of the default registered username.
+* Enhanced notification classification to properly identify "email" type
+  notifications and prompting users to check their e-mail inbox when such
+  notifications are clicked.
+* Resolved a bug in the language chooser that incorrectly greyed-out previously
+  selected languages, ensuring only the current language is non-selectable during
+  image upload.
+* Resolved pin color update issue in "Nearby" feature where the pin colour
+  failed to be updated after a successful image upload.
+
+What's listed here is only a subset of all the changes. Check the full-list of
+the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0).
+Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0)
+for an exhaustive list of changes and the various contributors who contributed the same.
+
+## v5.0.2
+
+- Enhanced multi-upload functionality with user prompts to clarify that all images would share the
+  same category and depictions.
+- Show Wikidata description on currently active Nearby pin to provide more useful information.
+- Improve the visibility of map markers by dynamically adjusting their colors based on the app's
+  theme. The map markers will now appear lighter when the app is in dark mode and darker when the
+  app is in light mode. This change aims to enhance marker visibility and improve the overall user
+  experience.
+- Added information on where user feedback is posted, helping users track existing feedback and
+  monitor their own submissions.
+- Enhanced the edit location screen of the upload screen by centering the map on the picture's
+  location from metadata when editing, or on the device's GPS location if metadata is unavailable,
+  improving accuracy and user experience.
+- Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a
+  recently uploaded image, enhancing clarity and user experience.
+- Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user
+  experience by showing loading progress until the image is fully loaded.
+- Fixed an issue where caption and description fields would intermittently disappear when using
+  voice input, ensuring text remains visible and stable across all entries.
+- Fixed a crash that occurred when attempting to remove multiple instances of caption/description
+  fields after initially adding them.
+- Improve the text in the prompt shown when skipping login to sound more natural.
+- Modified feedback addition logic to append new sections at the bottom of the page, ensuring
+  auto-archiving of sections functions correctly on the feedback page.
+- Resolved issue where the app failed to clear cookies upon logout.
+
+## v5.0.1
+
+Same as v5.0.0 except this fixes some R8 rules to ensure that the release
+variants of the app work as intended.
+
+## v5.0.0
+
+### What's Changed
+
+- Redesigned the map feature to **replace Mapbox with the osmdroid library**.
+  Key elements like pin visualization and user-centered display are still
+  included in this redesign. This is done to guard against possible misuse of
+  the Mapbox token and, more crucially, to keep the app from becoming dependent
+  on a service that charges for usage but offers a free tier.
+
+  With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org).
+- Add the ability to **export locations of nearby missing pictures in GPX and
+  KML formats**. This allows users to browse the locations with desired radius
+  for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing
+  accessibility  and offline functionality.
+- **Limited the uploads via the custom image picker** to a maximum of 20.
+- Added two menu choices for **transparent image backgrounds**, giving users the
+  option of either a black or white background, increasing adaptability to
+  various theme settings.
+
+  User customization option has been provided with the
+  ability to save background color selections permanently on a per image basis.
+- Implemented functionality to **automatically resume uploads** that become
+  stuck due to app termination or device reboot.
+- Added a **compass arrow in the Nearby banner** shown in the "Contributions"
+  screen to guide users towards the nearest item, thus providing the missing
+  directional cues. The arrow dynamically adjusts based on device rotation,
+  aligning with the calculated bearing towards the  target location. Further,
+  the distance and direction are updated as the user moves.
+- Implemented **voice input feature** for caption and description fields,
+  enabling users to dictate text directly into these fields.
+- Improved various flows in the app to **redirect users to the login page** and
+  display a  persistent message **if their session becomes invalid** due to a
+  password  change, enhancing user guidance and security measures.
+
+### Revamps and refactorings
+
+- **Revamped initial upload screen layout and the description edit screen layout**
+  for enhanced user experience and ensuring better symmetry in the design.
+- **Replaced Butterknife with ViewBinding** in various places of the app.
+- Transferred essential code from **the redundant data-client module** to the
+  main Commons app code, enabling its integration and facilitating the removal
+  of the redundant module. Further, convert various parts of the code to Kotlin.
+- **Revamped the various location permission flows** to ensure consistency for
+  the sake of a better user experience.
+
+### Bug fixes and various changes
+
+- Resolved an issue where paused uploads that were subsequently cancelled were
+  still being uploaded.
+- Fixed an issue where some user information such as upload count were not
+  displayed in the "Contributions" and "Profile" screens.
+- Fixed the long-standing broken *"Picture of the Day" widget* to restore its
+  usability.
+- Resolved an issue where some categories were hidden at the top of Upload
+  Wizard suggestions.
+- Resolved an issue where there was a grey empty screen at Upload wizard when
+  the app was denied the files permission.
+- Implemented logic to bypass media in Peer Review if the current reviewer is
+  also the user who uploaded the media.
+- Corrected arrow image behaviour in the first upload screen: now displays down
+  arrow when details card is fully visible, aligning with expected user
+  interaction.
+- Updated app icon to improve visibility and recognition on F-Droid.
+- Fixed issue causing all pictures to disappear and activity to reload fully in
+  the custom image selector after marking a picture as 'not for  upload', now
+  ensuring only the selected picture is removed as expected.
+
+What's listed here is only a subset of all the changes. Check the full-list of
+the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0).
+Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0)
+for an exhaustive list of changes and the various contributors who contributed the same.
+
+## v4.2.1
+
+- Provide the ability to edit an image to losslessly rotate it while uploading
+- Fix a bug in v4.2.0 where the nearby places were not loading
+- Fix a bug where editing depictions was showing a progress bar indefinitely
+- In the upload screen, use different map icons to indicate if image is being uploaded with location
+  metadata
+- For nearby uploads, it is no longer possible to deselect the item's category and depiction
+- The Mapbox account key used by the app has been changed
+- Category search now shows exact matches without any discrepancies
+- Various bug and crash fixes
+
+## v4.2.0
+- Dark mode colour improvements
+- Enhancements done to address location metadata loss including the metadata loss that occurs in
+  latest Android versions
+- Enhancements done to address the issue where uploads get stuck in queued state
+- Fix the inability to upload via the in-app camera option
+- Provide the ability to optionally include location metadata for in-app camera uploads in case the
+  device camera app does not provide location metadata
+- Use geo location URL that works consistently across all map applications
+- Fix crash when clicking on location target icon while trying to edit the location of an upload
+- Fix crash that occurs randomly while returning to the app after leaving it in the background
+- Fix crash in Sign up activity on Android version 5.0 and 5.1
+- Android 13 compatibility changes
+
+## v4.1.0
+- Location of pictures uploaded via custom picture selector are now recognized
+- Improvements to the custom picture selector
+- Ensure the WLM pictures are associated with the correct templates for each year
+- Only show pictures uploaded via app in peer review
+- Improve the variety of images show in peer review
+- Allow going to current location in location edit dialog while uploading a picture
+- Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox
+- Fixed various bugs
+
+## v4.0.5
+- Bumped min SDK to 29 to try and solve Google policy issue
+- Reverted dialog
+- Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published)
+
+## v4.0.4
+- Added dialog for Google's location policy
+
+## v4.0.3
+- Added "Report" button for Google UGC policy
+
+## v4.0.2
+- Fixed bug with wrong dates taken from EXIF
 - Fixed various crashes
 
+## v4.0.1
+- Fixed bug with no browser found
+- Updated Mapbox SDK to fix hamburger crash
+
+## v4.0.0
+- Added map showing nearby Commons pictures
+- Added custom SPARQL queries
+- Added user profiles
+- Added custom picture selector
+- Various bugfixes
+- Updated target SDK to 30
+
+## v3.1.1
+- Optimized Nearby query
+- Added Sweden's property for WLM 2021
+- Added link to wiki explaining how to contribute to WLM through app
+- Fixed various bugs and crashes
+
+## v3.1.0
+- Added Wiki Loves Monuments integration for WLM 2021
+
+## v3.0.2
+- Fixed crash when uploading high res image
+- Fixed crash when viewing images in Explore
+
+## v3.0.1
+- Pre-fill desc in Nearby uploads with Wikidata item's label + description
+- Improved ACRA crash reporting
+- Fixed various crashes
+
+## v3.0.0
+- Added Structured Data to upload workflow, users can now add depicts
+- Added Leaderboard in Achievements screen
+- Added to-do system for images with no categories/descriptions or with associated Wikipedia articles that have no pictures
+- Users can now modify and add categories to their uploads from the media details view
+- New UI for main screen
+- Limited connection mode added, users can now pause and resume uploads
+
 ## v2.13.1
 - Added OpenStreetMap attribution
 - Fixed various crashes
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 37ecfca1f..d8f293a25 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,9 +7,9 @@ Here's a gist of the guidelines,
 
 1. Make separate commits for logically separate changes
 
-1. Describe your changes well in the commit message
+2. Describe your changes well in the commit message
 
-    The first line of the commit message should be a short description of what has
+The first line of the commit message should be a short description of what has
 changed. It is also good to prefix the first line with "area: " where the "area"
 is a filename or identifier for the general area of the code being modified.
 The body should provide a meaningful commit message.
@@ -29,9 +29,9 @@ The body should provide a meaningful commit message.
       and if needed, Git allows us to see who wrote something without needing
       to add these tags (`git blame`)
 
-1. Write tests for your code (if possible)
+2. Write tests for your code (if possible)
 
-1. Make sure the Wiki pages don't become stale by updating them (if needed)
+3. Make sure the Wiki pages don't become stale by updating them (if needed)
 
 ### Further reading
 
diff --git a/CREDITS b/CREDITS
index e10d7ae5a..3fe6b00d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -47,12 +47,12 @@ their contribution to the product.
 * Alicia Bendz
 * Kaartic Sivaraam
 * Vanshika Arora
+* Seán Mac Gillicuddy
 
 3rd party open source libraries used:
 * Butterknife
 * GSON
 * Timber
-* MapBox
 
 3rd party open source apps from which significant code has been reused:
 * Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
deleted file mode 100644
index 12ff064e2..000000000
--- a/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,36 +0,0 @@
-**Summary:** 
-
-Summarize your issue in one sentence (what goes wrong, what did you expect to happen)
-
-_Before creating an issue, please search the existing issues to see if a similar one has already been created. You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter._
-
-**Steps to reproduce:** 
-
-How can we reproduce the issue? 
-What did you expect the app to do, and what did you see instead?
-
-**System logs:**
-
-```
-Add logcat files here (if possible).
-
-Need help? See https://github.com/commons-app/apps-android-commons/wiki/Getting-app-logs-from-Android-Studio
-```
-
-**Device and Android version:** 
-
-What make and model device (e.g., Samsung J7) did you encounter this on?
-What Android version (e.g., Android 4.0 Ice Cream Sandwich or Android 6.0 Marshmallow) are you running?
-Is it the stock version from the manufacturer or a custom ROM ?
- 
-**Commons app version:** 
-
-You can find this information by going to the navigation drawer in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. master and prodDebug).
-
-**Screen-shots:** 
-
-Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher.
-
-**Would you like to work on the issue?**
-
-Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them.
diff --git a/README.md b/README.md
index 06210d055..37f1a7872 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,21 @@
 # Wikimedia Commons Android app
-[](https://travis-ci.org/commons-app/apps-android-commons)
+
+[](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain)
 [](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw)
+[](https://codecov.io/gh/commons-app/apps-android-commons)
 
 The Wikimedia Commons Android app allows users to upload pictures from their Android phone/tablet to Wikimedia Commons. Download the app [here][1], or view our [website][2].
 
-Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request :-) 
+Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request! :-) 
 
 
- +
+ 
 
  ## Documentation
 
-We try to have an extensive documentation at [our wiki here at Github][4]:
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
 
 * [User Documentation][5]
 * [Contributor Documentation][6]
@@ -25,13 +27,14 @@ We try to have an extensive documentation at [our wiki here at Github][4]:
 
 Thank you all for your work!
 
-| [
 
 ## Documentation
 
-We try to have an extensive documentation at [our wiki here at Github][4]:
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
 
 * [User Documentation][5]
 * [Contributor Documentation][6]
@@ -25,13 +27,14 @@ We try to have an extensive documentation at [our wiki here at Github][4]:
 
 Thank you all for your work!
 
-| [
misaochan](https://github.com/misaochan) | [
yuvipanda](https://github.com/yuvipanda) | [
neslihanturan](https://github.com/neslihanturan) | [
maskaravivek](https://github.com/maskaravivek) | [
translatewiki](https://github.com/translatewiki) |
+| [
misaochan](https://github.com/misaochan) | [
translatewiki](https://github.com/translatewiki) | [
neslihanturan](https://github.com/neslihanturan) | [
yuvipanda](https://github.com/yuvipanda) | [
nicolas-raoul](https://github.com/nicolas-raoul) |
 | :---: | :---: | :---: | :---: | :---: |
-| [
nicolas-raoul](https://github.com/nicolas-raoul) | [
psh](https://github.com/psh) | [
brion](https://github.com/brion) | [
whym](https://github.com/whym) | [
domdomegg](https://github.com/domdomegg) |
-| [
akaita](https://github.com/akaita) | [
veyndan](https://github.com/veyndan) | [
ujjwalagrawal17](https://github.com/ujjwalagrawal17) | [
dbrant](https://github.com/dbrant) | [
sandarumk](https://github.com/sandarumk) |
-| [
tobias47n9e](https://github.com/tobias47n9e) | [
tanvidadu](https://github.com/tanvidadu) | [
hismaeel](https://github.com/hismaeel) | [
tshradheya](https://github.com/tshradheya) | [
diddypod](https://github.com/diddypod) |
-| [
prxjeen](https://github.com/prxjeen) | [
addshore](https://github.com/addshore) | [
knight-shade](https://github.com/knight-shade) | [
ashishkumar468](https://github.com/ashishkumar468) | [
siebrand](https://github.com/siebrand) |
-| [
Bluesir9](https://github.com/Bluesir9) | [
Jatin0312](https://github.com/Jatin0312) | [
mashawan](https://github.com/mashawan) | [
ford-prefect](https://github.com/ford-prefect) | [
seantnemann](https://github.com/seantnemann) |
+| [
psh](https://github.com/psh) | [
domdomegg](https://github.com/domdomegg) | [
maskaravivek](https://github.com/maskaravivek) | [
madhurgupta10](https://github.com/madhurgupta10) | [
ashishkumar468](https://github.com/ashishkumar468) |
+| [
bvibber](https://github.com/bvibber) | [
whym](https://github.com/whym) | [
akaita](https://github.com/akaita) | [
sivaraam](https://github.com/sivaraam) | [
veyndan](https://github.com/veyndan) |
+| [
ujjwalagrawal17](https://github.com/ujjwalagrawal17) | [
macgills](https://github.com/macgills) | [
amire80](https://github.com/amire80) | [
dbrant](https://github.com/dbrant) | [
vanshikaarora](https://github.com/vanshikaarora) |
+| [
RitikaPahwa4444](https://github.com/RitikaPahwa4444) | [
Ayan-10](https://github.com/Ayan-10) | [
rohit9625](https://github.com/rohit9625) | [
shashankiitbhu](https://github.com/shashankiitbhu) | [
Pratham2305](https://github.com/Pratham2305) |
+| [
parneet-guraya](https://github.com/parneet-guraya) | [
sandarumk](https://github.com/sandarumk) | [
tanvidadu](https://github.com/tanvidadu) | [
cypherop](https://github.com/cypherop) | [
Prince-kushwaha](https://github.com/Prince-kushwaha) |
+
 
 
 .. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).
@@ -43,13 +46,13 @@ This software is open source, licensed under the [Apache License 2.0][10].
 
 [1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
 [2]: https://commons-app.github.io/
-[3]: https://github.com/commons-app/apps-android-commons/issues
+[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
 
-[4]: https://github.com/commons-app/apps-android-commons/wiki
-[5]: https://github.com/commons-app/apps-android-commons/wiki#user-documentation
-[6]: https://github.com/commons-app/apps-android-commons/wiki#contributor-documentation
-[7]: https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome%21
-[8]: https://github.com/commons-app/apps-android-commons/wiki#developer-documentation
-[9]: https://github.com/commons-app/apps-android-commons/wiki/Libraries-used
+[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation
+[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation
+[6]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#️-contributor-documentation
+[7]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Volunteers-welcome!.md#volunteers-welcome
+[8]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-developer-documentation
+[9]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Libraries-used.md#libraries-used
 
 [10]: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 2b4ff253b..000000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,299 +0,0 @@
-plugins {
-    id 'com.github.triplet.play' version '2.7.2' apply false
-}
-apply from: '../gitutils.gradle'
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-kapt'
-apply plugin: 'kotlin-android-extensions'
-apply plugin: "com.hiya.jacoco-android"
-apply from: 'quality.gradle'
-
-def isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file('../play.p12').exists()
-
-if(isRunningOnTravisAndIsNotPRBuild) {
-    apply plugin: 'com.github.triplet.play'
-}
-
-dependencies {
-
-    // Utils
-    implementation 'com.github.nicolas-raoul:Quadtree:ac16ea8035bf07'
-    implementation 'in.yuvi:http.fluent:1.3'
-    implementation 'com.google.code.gson:gson:2.8.5'
-    implementation ("com.squareup.okhttp3:okhttp:$OKHTTP_VERSION"){
-        force = true //API 19 support
-    }
-    implementation 'com.squareup.okio:okio:2.2.2'
-    implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
-    implementation 'io.reactivex.rxjava2:rxjava:2.2.3'
-    implementation 'com.jakewharton.rxbinding2:rxbinding:2.1.1'
-    implementation 'com.jakewharton.rxbinding2:rxbinding-support-v4:2.1.1'
-    implementation 'com.jakewharton.rxbinding2:rxbinding-appcompat-v7:2.1.1'
-    implementation 'com.jakewharton.rxbinding2:rxbinding-design:2.1.1'
-    implementation 'com.facebook.fresco:fresco:1.13.0'
-    implementation 'org.apache.commons:commons-lang3:3.8.1'
-    implementation 'com.github.maskaravivek:wikimedia-android-data-client:v0.0.30'
-
-    // UI
-    implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
-    implementation 'com.github.pedrovgs:renderers:3.3.3'
-    implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:8.6.2'
-    implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-localization-v8:0.11.0'
-    implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-scalebar-v9:0.4.0'
-    implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
-    implementation 'com.dinuscxj:circleprogressbar:1.1.1'
-    implementation 'com.karumi:dexter:5.0.0'
-    implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
-    kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
-
-    // Logging
-    implementation 'ch.acra:acra-dialog:5.3.0'
-    implementation 'ch.acra:acra-mail:5.3.0'
-    implementation 'org.slf4j:slf4j-api:1.7.25'
-    api('com.github.tony19:logback-android-classic:1.1.1-6') {
-        exclude group: 'com.google.android', module: 'android'
-    }
-    implementation "com.squareup.okhttp3:logging-interceptor:$OKHTTP_VERSION"
-
-    // Dependency injector
-    implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
-    kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
-    kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
-
-    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
-    implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION"
-
-    //Mocking
-    testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
-    testImplementation 'org.mockito:mockito-inline:2.8.47'
-    testImplementation 'org.mockito:mockito-core:2.23.0'
-    testImplementation "org.powermock:powermock-module-junit4:2.0.0-beta.5"
-    testImplementation "org.powermock:powermock-api-mockito2:2.0.0-beta.5"
-
-    // Unit testing
-    testImplementation 'junit:junit:4.12'
-    testImplementation 'org.robolectric:robolectric:4.3'
-    testImplementation 'androidx.test:core:1.2.0'
-    testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.1'
-    testImplementation "org.powermock:powermock-module-junit4:2.0.0-beta.5"
-    testImplementation "org.powermock:powermock-api-mockito2:2.0.0-beta.5"
-    testImplementation 'org.mockito:mockito-core:2.23.0'
-
-    // Android testing
-    androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
-    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
-    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
-    androidTestImplementation 'androidx.test:runner:1.2.0'
-    androidTestImplementation 'androidx.test:rules:1.2.0'
-    androidTestImplementation 'androidx.annotation:annotation:1.1.0'
-    androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.12.1'
-    androidTestUtil 'androidx.test:orchestrator:1.2.0'
-
-    // Debugging
-    debugImplementation "com.squareup.leakcanary:leakcanary-android:$LEAK_CANARY_VERSION"
-    releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
-    testImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
-
-    // Support libraries
-    implementation "com.google.android.material:material:1.1.0-alpha04"
-    implementation "androidx.browser:browser:1.0.0"
-    implementation "androidx.cardview:cardview:1.0.0"
-    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
-    implementation "androidx.exifinterface:exifinterface:1.0.0"
-
-    //swipe_layout
-    implementation 'com.daimajia.swipelayout:library:1.2.0@aar'
-
-    //Room
-    implementation "androidx.room:room-runtime:$ROOM_VERSION"
-    kapt "androidx.room:room-compiler:$ROOM_VERSION" // For Kotlin use kapt instead of annotationProcessor
-    implementation 'com.squareup.retrofit2:retrofit:2.7.1'
-    implementation "androidx.room:room-rxjava2:$ROOM_VERSION"
-    testImplementation "androidx.arch.core:core-testing:2.1.0"
-
-    // Pref
-    // Java language implementation
-    implementation "androidx.preference:preference:$PREFERENCE_VERSION"
-    // Kotlin
-    implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION"
-}
-
-android {
-    compileSdkVersion 28
-    buildToolsVersion "28.0.3"
-
-    defaultConfig {
-        //applicationId 'fr.free.nrw.commons'
-        versionCode 791
-        versionName '2.13.2'
-        setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
-
-        minSdkVersion 19
-        targetSdkVersion 28
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        testInstrumentationRunnerArguments clearPackageData: 'true'
-
-        testOptions {
-            execution 'ANDROIDX_TEST_ORCHESTRATOR'
-        }
-
-        vectorDrawables.useSupportLibrary = true
-    }
-
-    packagingOptions {
-        exclude 'META-INF/androidx.*'
-        exclude 'META-INF/proguard/androidx-annotations.pro'
-    }
-
-    testOptions {
-        unitTests.returnDefaultValues = true
-        unitTests.includeAndroidResources = true
-
-        unitTests.all {
-            jvmArgs '-noverify'
-        }
-    }
-
-    sourceSets {
-        // use kotlin only in tests (for now)
-        test.java.srcDirs += 'src/test/kotlin'
-
-        // use main assets and resources in test
-        test.assets.srcDirs += 'src/main/assets'
-        test.resources.srcDirs += 'src/main/resoures'
-    }
-
-	signingConfigs {
-        release
-    }
-
-    buildTypes {
-        release {
-            minifyEnabled true
-            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
-            testProguardFile 'test-proguard-rules.txt'
-            if(isRunningOnTravisAndIsNotPRBuild) {
-                signingConfig signingConfigs.release
-            }
-        }
-        debug {
-            minifyEnabled true
-            testCoverageEnabled project.hasProperty('coverage')
-            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
-            testProguardFile 'test-proguard-rules.txt'
-            versionNameSuffix "-debug-" + getBranchName()
-        }
-    }
-
-    if (isRunningOnTravisAndIsNotPRBuild) {
-        // configure keystore based on env vars in Travis for automated alpha builds
-        signingConfigs.release.storeFile = file("../nr-commons.keystore")
-        signingConfigs.release.storePassword = System.getenv("keystore_password")
-        signingConfigs.release.keyAlias = System.getenv("key_alias")
-        signingConfigs.release.keyPassword = System.getenv("key_password")
-    }
-
-    configurations.all {
-        resolutionStrategy.force 'androidx.annotation:annotation:1.0.2'
-    }
-
-    flavorDimensions 'tier'
-    productFlavors {
-        prod {
-
-			applicationId 'fr.free.nrw.commons'
-
-            buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
-            buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\""
-            buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
-            buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
-            buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\""
-            buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\""
-            buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\""
-            buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\""
-            buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
-            buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\""
-            buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
-            buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\""
-            buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\""
-            buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
-            buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\""
-            buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
-            buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\""
-            buildConfigField "String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\""
-            buildConfigField "String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\""
-            buildConfigField "String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\""
-            buildConfigField "String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\""
-            buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\""
-            buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\""
-            buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
-
-            dimension 'tier'
-        }
-
-        beta {
-            applicationId 'fr.free.nrw.commons.beta'
-
-            // What values do we need to hit the BETA versions of the site / api ?
-            buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
-            buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\""
-            buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
-            buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
-            buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\""
-            buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\""
-            buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\""
-            buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\""
-            buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
-            buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\""
-            buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
-            buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\""
-            buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\""
-            buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
-            buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\""
-            buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
-            buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""
-            buildConfigField "String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\""
-            buildConfigField "String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\""
-            buildConfigField "String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\""
-            buildConfigField "String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\""
-            buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\""
-            buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\""
-            buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
-
-            dimension 'tier'
-        }
-    }
-
-    lintOptions {
-        disable 'MissingTranslation'
-        disable 'ExtraTranslation'
-        abortOnError false
-    }
-
-    compileOptions {
-        sourceCompatibility JavaVersion.VERSION_1_8
-        targetCompatibility JavaVersion.VERSION_1_8
-    }
-
-    buildToolsVersion buildToolsVersion
-}
-
-if(isRunningOnTravisAndIsNotPRBuild) {
-    play {
-        track = "alpha"
-        userFraction = 1
-        serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
-        serviceAccountCredentials = file("../play.p12")
-
-        resolutionStrategy = "auto"
-        outputProcessor { // this: ApkVariantOutput
-            versionNameOverride = "$versionNameOverride.$versionCode"
-        }
-    }
-}
-
-androidExtensions {
-    experimental = true
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 000000000..41788128c
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,447 @@
+import java.util.Properties
+import java.io.ByteArrayOutputStream
+
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.jetbrains.kotlin.android)
+    alias(libs.plugins.kotlin.kapt)
+    alias(libs.plugins.kotlin.parcelize)
+}
+
+apply(from = "$rootDir/jacoco.gradle")
+
+val isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file("../play.p12").exists()
+
+if (isRunningOnTravisAndIsNotPRBuild) {
+    apply(plugin = "com.github.triplet.play")
+}
+
+android {
+    namespace = "fr.free.nrw.commons"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "fr.free.nrw.commons"
+        minSdk = 21
+        targetSdk = 35
+        versionCode = 1059
+        versionName = "6.1.0"
+
+        setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunnerArguments["clearPackageData"] = "true"
+
+        multiDexEnabled = true
+
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    sourceSets {
+        getByName("test") {
+            // Use kotlin only in tests (for now)
+            java.srcDirs("src/test/kotlin")
+
+            // Use main assets and resources in test
+            assets.srcDirs("src/main/assets")
+            resources.srcDirs("src/main/resources")
+        }
+    }
+
+    signingConfigs {
+        create("release") {
+            // Configure keystore based on env vars in Travis for automated alpha builds
+            if(isRunningOnTravisAndIsNotPRBuild) {
+                storeFile = file("../nr-commons.keystore")
+                storePassword = System.getenv("keystore_password")
+                keyAlias = System.getenv("key_alias")
+                keyPassword = System.getenv("key_password")
+            }
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = true
+            proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
+            testProguardFile("test-proguard-rules.txt")
+
+            signingConfig = signingConfigs.getByName("debug")
+            if (isRunningOnTravisAndIsNotPRBuild) {
+                signingConfig = signingConfigs.getByName("release")
+            }
+        }
+        debug {
+            isMinifyEnabled = false
+            proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
+            testProguardFile("test-proguard-rules.txt")
+
+            versionNameSuffix = "-debug-" + getBranchName()
+            enableUnitTestCoverage = true
+            enableAndroidTestCoverage = true
+        }
+    }
+
+    configurations.all {
+        resolutionStrategy {
+            force("androidx.annotation:annotation:1.1.0")
+            force("com.jakewharton.timber:timber:4.7.1")
+            force("androidx.fragment:fragment:1.3.6")
+        }
+        exclude(module = "okhttp-ws")
+    }
+
+    flavorDimensions += "tier"
+    productFlavors {
+        create("prod") {
+            dimension = "tier"
+            applicationId = "fr.free.nrw.commons"
+
+            buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
+            buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"")
+            buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
+            buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+            buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
+            buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"")
+            buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"")
+            buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"")
+            buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.org\"")
+            buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+            buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"")
+            buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"")
+            buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
+            buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"")
+            buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"")
+            buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
+            buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
+            buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"")
+            buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"")
+            buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"")
+            buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\"")
+            buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\"")
+            buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.recentlanguages.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.items.contentprovider\"")
+            buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
+            buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
+            buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
+            buildConfigField("String", "DEPICTS_PROPERTY", "\"P180\"")
+            buildConfigField("String", "CREATOR_PROPERTY", "\"P170\"")
+        }
+
+        create("beta") {
+            dimension = "tier"
+            applicationId = "fr.free.nrw.commons.beta"
+
+            // What values do we need to hit the BETA versions of the site / api ?
+            buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
+            buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"")
+            buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
+            buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+            buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
+            buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"")
+            buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"")
+            buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"")
+            buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"")
+            buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+            buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"")
+            buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"")
+            buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
+            buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"")
+            buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"")
+            buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
+            buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
+            buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"")
+            buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"")
+            buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"")
+            buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\"")
+            buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\"")
+            buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.beta.recentlanguages.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\"")
+            buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.items.contentprovider\"")
+            buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
+            buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
+            buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
+            buildConfigField("String", "DEPICTS_PROPERTY", "\"P245962\"")
+            buildConfigField("String", "CREATOR_PROPERTY", "\"P253075\"")
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        buildConfig = true
+        viewBinding = true
+        compose = true
+    }
+    buildToolsVersion = buildToolsVersion
+    composeOptions {
+        kotlinCompilerExtensionVersion = "1.5.8"
+    }
+    packaging {
+        jniLibs {
+            excludes += listOf("META-INF/androidx.*")
+        }
+        resources {
+            excludes += listOf(
+                "META-INF/androidx.*",
+                "META-INF/proguard/androidx-annotations.pro",
+                "/META-INF/LICENSE.md",
+                "/META-INF/LICENSE-notice.md"
+            )
+        }
+    }
+    testOptions {
+        animationsDisabled = true
+        unitTests {
+            isReturnDefaultValues = true
+            isIncludeAndroidResources = true
+        }
+        unitTests.all {
+            it.jvmArgs("-noverify")
+        }
+    }
+    lint {
+        abortOnError = false
+        disable += listOf("MissingTranslation", "ExtraTranslation")
+    }
+}
+
+dependencies {
+    // Utils
+    implementation(libs.gson)
+    implementation(libs.okhttp)
+    implementation(libs.retrofit)
+    implementation(libs.retrofit.converter.gson)
+    implementation(libs.retrofit.adapter.rxjava)
+    implementation(libs.rxandroid)
+    implementation(libs.rxjava)
+    implementation(libs.rxbinding)
+    implementation(libs.rxbinding.appcompat)
+    implementation(libs.facebook.fresco)
+    implementation(libs.facebook.fresco.middleware)
+    implementation(libs.apache.commons.lang3)
+
+    // UI
+    implementation("${libs.viewpagerindicator.library.get()}@aar")
+    implementation(libs.photoview)
+    implementation(libs.android.sdk)
+    implementation(libs.android.plugin.scalebar)
+
+    implementation(libs.timber)
+    implementation(libs.android.material)
+    implementation(libs.dexter)
+
+    // Jetpack Compose
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.runtime)
+    implementation(libs.androidx.ui)
+    implementation(libs.androidx.ui.graphics)
+    implementation(libs.androidx.ui.tooling.preview)
+    implementation(libs.androidx.ui.viewbinding)
+    implementation(libs.androidx.material3)
+    implementation(libs.androidx.foundation)
+    implementation(libs.androidx.foundation.layout)
+    androidTestImplementation(platform(libs.androidx.compose.bom))
+    androidTestImplementation(libs.androidx.ui.test.junit4)
+    debugImplementation(libs.androidx.ui.tooling)
+    debugImplementation(libs.androidx.ui.test.manifest)
+
+    implementation(libs.adapterdelegates4.kotlin.dsl.viewbinding)
+    implementation(libs.adapterdelegates4.pagination)
+    implementation(libs.androidx.paging.runtime.ktx)
+    testImplementation(libs.androidx.paging.common.ktx)
+    implementation(libs.androidx.paging.rxjava2.ktx)
+    implementation(libs.androidx.recyclerview)
+
+    // Logging
+    implementation(libs.acra.dialog)
+    implementation(libs.acra.mail)
+    implementation(libs.slf4j.api)
+    implementation(libs.logback.android.classic) {
+        exclude(group = "com.google.android", module = "android")
+    }
+    implementation(libs.logging.interceptor)
+
+    // Dependency injector
+    implementation(libs.dagger.android)
+    implementation(libs.dagger.android.support)
+    kapt(libs.dagger.android.processor)
+    kapt(libs.dagger.compiler)
+    annotationProcessor(libs.dagger.android.processor)
+
+    implementation(libs.kotlin.reflect)
+
+    //Mocking
+    testImplementation(libs.mockito.kotlin)
+    testImplementation(libs.mockito.core)
+    testImplementation(libs.powermock.module.junit)
+    testImplementation(libs.powermock.api.mockito)
+    testImplementation(libs.mockk)
+
+    // Unit testing
+    testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.androidx.test.core)
+    testImplementation(libs.androidx.runner)
+    testImplementation(libs.androidx.test.ext.junit)
+    testImplementation(libs.androidx.test.rules)
+    testImplementation(libs.mockwebserver)
+    testImplementation(libs.livedata.testing.ktx)
+    testImplementation(libs.androidx.core.testing)
+    testImplementation(libs.junit.jupiter.api)
+    testRuntimeOnly(libs.junit.jupiter.engine)
+    testImplementation(libs.soloader)
+    testImplementation(libs.kotlinx.coroutines.test)
+    debugImplementation(libs.androidx.fragment.testing)
+    testImplementation(libs.commons.io)
+
+    // Android testing
+    androidTestImplementation(libs.androidx.espresso.core)
+    androidTestImplementation(libs.androidx.espresso.intents)
+    androidTestImplementation(libs.androidx.espresso.contrib)
+    androidTestImplementation(libs.androidx.runner)
+    androidTestImplementation(libs.androidx.test.rules)
+    androidTestImplementation(libs.androidx.test.core)
+    androidTestImplementation(libs.androidx.test.ext.junit)
+    androidTestImplementation(libs.androidx.annotation)
+    androidTestImplementation(libs.mockwebserver)
+    androidTestImplementation(libs.androidx.uiautomator)
+
+    // Debugging
+    debugImplementation(libs.leakcanary.android)
+
+    // Support libraries
+    implementation(libs.androidx.browser)
+    implementation(libs.androidx.cardview)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.androidx.exifinterface)
+    implementation(libs.recyclerview.fastscroll)
+
+    //swipe_layout
+    implementation(libs.swipelayout.library)
+
+    //Room
+    implementation(libs.androidx.room.runtime)
+    implementation(libs.androidx.room.ktx)
+    implementation(libs.androidx.room.rxjava)
+    kapt(libs.androidx.room.compiler)
+
+    // Preferences
+    implementation(libs.androidx.preference)
+    implementation(libs.androidx.preference.ktx)
+
+    //Android Media
+    implementation(libs.juanitobananas.androidDmediaUtil)
+    implementation(libs.androidx.multidex)
+
+    // Kotlin + coroutines
+    implementation(libs.androidx.work.runtime.ktx)
+    implementation(libs.androidx.work.runtime)
+    implementation(libs.kotlinx.coroutines.rx2)
+    testImplementation(libs.androidx.work.testing)
+
+    //Glide
+    implementation(libs.glide)
+    annotationProcessor(libs.glide.compiler)
+    kaptTest(libs.androidx.databinding.compiler)
+    kaptAndroidTest(libs.androidx.databinding.compiler)
+
+    implementation(libs.coordinates2country.android) {
+        exclude(group = "com.google.android", module = "android")
+    }
+
+    //OSMDroid
+    implementation(libs.osmdroid.android)
+    constraints {
+        implementation(libs.kotlin.stdlib.jdk7) {
+            because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
+        }
+        implementation(libs.kotlin.stdlib.jdk8) {
+            because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
+        }
+    }
+}
+
+tasks.register("disableAnimations") {
+    val adb = "${System.getenv("ANDROID_HOME")}/platform-tools/adb"
+    commandLine(adb, "shell", "settings", "put", "global", "window_animation_scale", "0")
+    commandLine(adb, "shell", "settings", "put", "global", "transition_animation_scale", "0")
+    commandLine(adb, "shell", "settings", "put", "global", "animator_duration_scale", "0")
+}
+
+project.gradle.taskGraph.whenReady {
+    val connectedBetaDebugAndroidTest = tasks.named("connectedBetaDebugAndroidTest")
+    val connectedProdDebugAndroidTest = tasks.named("connectedProdDebugAndroidTest")
+
+    connectedBetaDebugAndroidTest.configure {
+        dependsOn("disableAnimations")
+    }
+    connectedProdDebugAndroidTest.configure {
+        dependsOn("disableAnimations")
+    }
+}
+
+fun getTestUserName(): String? {
+    val propFile = rootProject.file("./local.properties")
+    val properties = Properties()
+    propFile.inputStream().use { properties.load(it) }
+    return properties.getProperty("TEST_USER_NAME")
+}
+
+fun getTestPassword(): String? {
+    val propFile = rootProject.file("./local.properties")
+    val properties = Properties()
+    propFile.inputStream().use { properties.load(it) }
+    return properties.getProperty("TEST_USER_PASSWORD")
+}
+
+if (isRunningOnTravisAndIsNotPRBuild) {
+    configure {
+        track = "alpha"
+        userFraction = 1.0
+        serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
+        serviceAccountCredentials = file("../play.p12")
+
+        resolutionStrategy = "auto"
+        outputProcessor { // this: ApkVariantOutput
+            versionNameOverride = "$versionNameOverride.$versionCode"
+        }
+    }
+}
+
+fun getBuildVersion(): String? {
+    return try {
+        val stdout = ByteArrayOutputStream()
+        exec {
+            commandLine("git", "rev-parse", "--short", "HEAD")
+            standardOutput = stdout
+        }
+        stdout.toString().trim()
+    } catch (e: Exception) {
+        null
+    }
+}
+
+fun getBranchName(): String? {
+    return try {
+        val stdout = ByteArrayOutputStream()
+        exec {
+            commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
+            standardOutput = stdout
+        }
+        stdout.toString().trim()
+    } catch (e: Exception) {
+        null
+    }
+}
diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt
index c71f80777..21c584ba9 100644
--- a/app/proguard-rules.txt
+++ b/app/proguard-rules.txt
@@ -31,6 +31,17 @@
 -keepattributes Signature
 # Retain declared checked exceptions for use by a Proxy instance.
 -keepattributes Exceptions
+
+# Note: The model package right now seems to include some other classes that
+# are not used for serialization / deserialization over Gson. Hopefully
+# that's not a problem since it only prevents R8 from avoiding trimming
+# of few more classes.
+-keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.actions.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.auth.csrf.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.auth.login.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.wikidata.mwapi.** { *; }
+
 # --- /Retrofit ---
 
 # --- OkHttp + Okio ---
@@ -55,6 +66,9 @@
 # Application classes that will be serialized/deserialized over Gson
 -keep class com.google.gson.examples.android.model.** { *; }
 
+# Prevent R8 from obfuscating project classes used by Gson for parsing
+-keep class fr.free.nrw.commons.fileusages.** { *; }
+
 # Prevent proguard from stripping interface information from TypeAdapterFactory,
 # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
 -keep class * implements com.google.gson.TypeAdapterFactory
diff --git a/app/quality.gradle b/app/quality.gradle
deleted file mode 100644
index 1afdf0d68..000000000
--- a/app/quality.gradle
+++ /dev/null
@@ -1,45 +0,0 @@
-apply plugin: 'checkstyle'
-apply plugin: 'pmd'
-
-check.dependsOn 'checkstyle', 'pmd'
-
-checkstyle {
-    toolVersion = '7.5.1'
-}
-
-task checkstyle(type: Checkstyle) {
-    configFile file("${project.rootDir}/script/style/checkstyle.xml")
-    source 'src'
-    include '**/*.java'
-    exclude '**/gen/**'
-
-    classpath = files()
-
-    reports {
-        html {
-            enabled true
-            destination file("${project.buildDir}/reports/checkstyle/checkstyle.html")
-        }
-    }
-}
-
-task pmd(type: Pmd) {
-    ignoreFailures = true
-    ruleSetFiles = files("${project.rootDir}/script/style/ruleset.xml")
-    ruleSets = []
-
-    source 'src'
-    include '**/*.java'
-    exclude '**/gen/**'
-
-    reports {
-        xml.enabled = false
-        html.enabled = true
-        xml {
-            destination file("${project.buildDir}/reports/pmd/pmd.xml")
-        }
-        html {
-            destination file("${project.buildDir}/reports/pmd/pmd.html")
-        }
-    }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
index 1ee2a5ace..50dfe8e7f 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
@@ -3,7 +3,6 @@ package fr.free.nrw.commons
 import android.app.Activity
 import android.app.Instrumentation
 import android.content.Intent
-import androidx.test.InstrumentationRegistry
 import androidx.test.core.app.ApplicationProvider.getApplicationContext
 import androidx.test.espresso.Espresso
 import androidx.test.espresso.action.ViewActions
@@ -12,10 +11,13 @@ import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.intent.matcher.IntentMatchers
 import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.utils.ConfigUtils
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
 import org.hamcrest.CoreMatchers
+import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -26,82 +28,122 @@ class AboutActivityTest {
     @get:Rule
     var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
 
+    private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
     @Before
     fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
         Intents.init()
-        Intents.intending(CoreMatchers.not(IntentMatchers.isInternal()))
-                .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+        Intents
+            .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+    }
+
+    @After
+    fun cleanUp() {
+        Intents.release()
     }
 
     @Test
     fun testBuildNumber() {
-        Espresso.onView(ViewMatchers.withId(R.id.about_version))
-                .check(ViewAssertions.matches(withText(ConfigUtils.getVersionNameWithSha(getApplicationContext()))))
+        Espresso
+            .onView(ViewMatchers.withId(R.id.about_version))
+            .check(
+                ViewAssertions.matches(
+                    withText(getApplicationContext().getVersionNameWithSha()),
+                ),
+            )
     }
 
     @Test
     fun testLaunchWebsite() {
         Espresso.onView(ViewMatchers.withId(R.id.website_launch_icon)).perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData(Urls.WEBSITE_URL)))
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.WEBSITE_URL),
+            ),
+        )
     }
 
     @Test
     fun testLaunchFacebook() {
         Espresso.onView(ViewMatchers.withId(R.id.facebook_launch_icon)).perform(ViewActions.click())
-        Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
-        Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
-                IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME)))
+        Intents.intended(
+            CoreMatchers.anyOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
+                IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME),
+            ),
+        )
     }
 
     @Test
     fun testLaunchGithub() {
         Espresso.onView(ViewMatchers.withId(R.id.github_launch_icon)).perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData(Urls.GITHUB_REPO_URL)))
-    }
-
-    @Test
-    fun testLaunchRateUs() {
-        val appPackageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
-        Espresso.onView(ViewMatchers.withId(R.id.about_rate_us)).perform(ViewActions.click())
-        Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
-        Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName"),
-                IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName")))
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.GITHUB_REPO_URL),
+            ),
+        )
     }
 
     @Test
     fun testLaunchAboutPrivacyPolicy() {
         Espresso.onView(ViewMatchers.withId(R.id.about_privacy_policy)).perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL)))
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL),
+            ),
+        )
     }
 
     @Test
     fun testLaunchTranslate() {
         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
-        val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0]
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode")))
+        val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"),
+            ),
+        )
     }
 
     @Test
     fun testLaunchAboutCredits() {
         Espresso.onView(ViewMatchers.withId(R.id.about_credits)).perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData(Urls.CREDITS_URL)))
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.CREDITS_URL),
+            ),
+        )
+    }
+
+    @Test
+    fun testLaunchUserGuide() {
+        Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click())
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.USER_GUIDE_URL),
+            ),
+        )
     }
 
     @Test
     fun testLaunchAboutFaq() {
         Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
-                IntentMatchers.hasData(Urls.FAQ_URL)))
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(Urls.FAQ_URL),
+            ),
+        )
     }
-
-    @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
-    }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt
deleted file mode 100644
index 198578f2f..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.contrib.DrawerActions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
-import androidx.test.espresso.intent.rule.IntentsTestRule
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.filters.MediumTest
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.achievements.AchievementsActivity
-import fr.free.nrw.commons.auth.LoginActivity
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class AchievementsActivityTest {
-    @get:Rule
-    var activityRule = IntentsTestRule(LoginActivity::class.java)
-
-    @Before
-    fun setup() {
-        UITestHelper.skipWelcome()
-        UITestHelper.loginUser()
-    }
-
-    @Test
-    fun testAchievements() {
-        onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
-        onView(withId(R.id.user_icon)).perform(click())
-
-        Intents.intended(hasComponent(AchievementsActivity::class.java.name))
-    }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ExploreActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ExploreActivityTest.kt
deleted file mode 100644
index aba45f691..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/ExploreActivityTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import fr.free.nrw.commons.explore.categories.ExploreActivity
-
-@RunWith(AndroidJUnit4::class)
-class ExploreActivityTest {
-    @get:Rule
-    var activityRule = ActivityTestRule(ExploreActivity::class.java)
-
-    @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
-    }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
index ac2eb6ff7..9bfc9321b 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
@@ -8,15 +8,17 @@ import androidx.test.espresso.action.ViewActions
 import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.intent.Intents.intending
 import androidx.test.espresso.intent.matcher.IntentMatchers
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
 import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal
 import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
 import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.auth.SignupActivity
 import org.hamcrest.CoreMatchers
 import org.hamcrest.CoreMatchers.not
+import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -27,33 +29,41 @@ class LoginActivityTest {
     @get:Rule
     var activityRule = ActivityTestRule(LoginActivity::class.java)
 
+    private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
     @Before
     fun setup() {
-        try {
-            Intents.init()
-        } catch (ex: IllegalStateException) {
-
-        }
+        device.setOrientationNatural()
+        device.freezeRotation()
+        Intents.init()
         UITestHelper.skipWelcome()
         intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null))
     }
 
-    @Test
-    fun testLogin() {
-        UITestHelper.loginUser()
-        Intents.intended(hasComponent(MainActivity::class.java.name))
+    @After
+    fun cleanUp() {
+        Intents.release()
     }
 
     @Test
     fun testForgotPassword() {
-        UITestHelper.sleep(3000)
-        Espresso.onView(ViewMatchers.withId(R.id.forgot_password))
-                .perform(ViewActions.click())
-        Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL)));
+        Espresso.onView(ViewMatchers.withId(R.id.forgot_password)).perform(ViewActions.click())
+        Intents.intended(
+            CoreMatchers.allOf(
+                IntentMatchers.hasAction(Intent.ACTION_VIEW),
+                IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL),
+            ),
+        )
+    }
+
+    @Test
+    fun testSignupButton() {
+        Espresso.onView(ViewMatchers.withId(R.id.sign_up_button)).perform(ViewActions.click())
+        Intents.intended(IntentMatchers.hasComponent(SignupActivity::class.java.name))
     }
 
     @Test
     fun orientationChange() {
         UITestHelper.changeOrientation(activityRule)
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
index 0b34e5c1e..3d2fc9e48 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
@@ -1,19 +1,214 @@
 package fr.free.nrw.commons
 
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.contributions.MainActivity
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import com.google.gson.Gson
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.notification.NotificationActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.After
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
+@LargeTest
 @RunWith(AndroidJUnit4::class)
 class MainActivityTest {
     @get:Rule
-    var activityRule = ActivityTestRule(MainActivity::class.java)
+    var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
+
+    @get:Rule
+    var mGrantPermissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(
+            "android.permission.ACCESS_FINE_LOCATION",
+        )
+
+    private val device: UiDevice =
+        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    private lateinit var defaultKvStore: JsonKvStore
+
+    @Before
+    fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
+        UITestHelper.loginUser()
+        UITestHelper.skipWelcome()
+        Intents.init()
+        Intents
+            .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+        val context = InstrumentationRegistry.getInstrumentation().targetContext
+        val storeName = context.packageName + "_preferences"
+        defaultKvStore = JsonKvStore(context, storeName, Gson())
+    }
+
+    @After
+    fun cleanUp() {
+        Intents.release()
+    }
 
     @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
+    fun testNearby() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        1,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        Espresso
+            .onView(ViewMatchers.withId(R.id.fragmentContainer))
+            .check(matches(ViewMatchers.isDisplayed()))
+        UITestHelper.sleep(10000)
+        val actionMenuItemView2 =
+            Espresso.onView(
+                Matchers.allOf(
+                    ViewMatchers.withId(R.id.list_sheet),
+                    ViewMatchers.withContentDescription("List"),
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.toolbar),
+                            1,
+                        ),
+                        0,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            )
+        actionMenuItemView2.perform(ViewActions.click())
+        UITestHelper.sleep(1000)
     }
-}
\ No newline at end of file
+
+    @Test
+    fun testExplore() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        2,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        Espresso
+            .onView(ViewMatchers.withId(R.id.fragmentContainer))
+            .check(matches(ViewMatchers.isDisplayed()))
+        UITestHelper.sleep(1000)
+    }
+
+    @Test
+    fun testContributions() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        0,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        Espresso
+            .onView(ViewMatchers.withId(R.id.fragmentContainer))
+            .check(matches(ViewMatchers.isDisplayed()))
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    ViewMatchers.withId(R.id.contributionImage),
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.contributionsList),
+                            0,
+                        ),
+                        1,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        val actionMenuItemView =
+            Espresso.onView(
+                Matchers.allOf(
+                    ViewMatchers.withId(R.id.menu_bookmark_current_image),
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.toolbar),
+                            1,
+                        ),
+                        0,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            )
+        actionMenuItemView.perform(ViewActions.click())
+        UITestHelper.sleep(3000)
+    }
+
+    @Test
+    fun testBookmarks() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        3,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        UITestHelper.sleep(1000)
+    }
+
+    @Test
+    fun testNotifications() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    ViewMatchers.withId(R.id.notifications),
+                    childAtPosition(
+                        childAtPosition(
+                            ViewMatchers.withId(R.id.toolbar),
+                            1,
+                        ),
+                        1,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name))
+        Espresso.pressBack()
+        UITestHelper.sleep(1000)
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/NavigationBaseActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/NavigationBaseActivityTest.kt
deleted file mode 100644
index 7a9f95c63..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/NavigationBaseActivityTest.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.contrib.DrawerActions
-import androidx.test.espresso.contrib.NavigationViewActions
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.filters.LargeTest
-import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@LargeTest
-@RunWith(AndroidJUnit4::class)
-class NavigationBaseActivityTest {
-    @get:Rule
-    var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
-
-    /**
-     * Goes through all the activities in the app and checks we don't crash
-     * NB: This is not realistic if we're not logged in; we can access 'home', 'notifications', 'settings' and 'achievements' which we wouldn't otherwise be able to.
-     */
-    @Test
-    fun goThroughNavigationBaseActivityActivities() {
-        // Home
-        openNavigationDrawerAndNavigateTo(R.id.action_home)
-
-        // Explore
-        openNavigationDrawerAndNavigateTo(R.id.action_explore)
-
-        // Bookmarks
-        openNavigationDrawerAndNavigateTo(R.id.action_bookmarks)
-
-        // About
-        openNavigationDrawerAndNavigateTo(R.id.action_about)
-
-        // Settings
-        openNavigationDrawerAndNavigateTo(R.id.action_settings)
-
-        // Achievements
-        openNavigationDrawerAndNavigateTo(R.id.action_login)
-    }
-
-    private fun openNavigationDrawerAndNavigateTo(menuItemId: Int) {
-        onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
-        UITestHelper.sleep(500)
-        onView(withId(R.id.navigation_view)).perform(NavigationViewActions.navigateTo(menuItemId))
-    }
-
-    @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
-    }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt
new file mode 100644
index 000000000..003fc0674
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt
@@ -0,0 +1,67 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
+import androidx.test.espresso.intent.rule.IntentsTestRule
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.profile.ProfileActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ProfileActivityTest {
+    @get:Rule
+    var activityRule = IntentsTestRule(LoginActivity::class.java)
+
+    private val device: UiDevice = UiDevice.getInstance(getInstrumentation())
+
+    @Before
+    fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
+        UITestHelper.loginUser()
+        UITestHelper.skipWelcome()
+        Intents
+            .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+    }
+
+    @Test
+    fun testProfile() {
+        onView(
+            Matchers.allOf(
+                ViewMatchers.withContentDescription("More"),
+                childAtPosition(
+                    childAtPosition(
+                        withId(R.id.fragment_main_nav_tab_layout),
+                        0,
+                    ),
+                    4,
+                ),
+                ViewMatchers.isDisplayed(),
+            ),
+        ).perform(ViewActions.click())
+        onView(Matchers.allOf(withId(R.id.more_profile))).perform(
+            ViewActions.scrollTo(),
+            ViewActions.click(),
+        )
+        device.swipe(1033, 1346, 531, 1346, 20)
+        UITestHelper.sleep(5000)
+        Intents.intended(hasComponent(ProfileActivity::class.java.name))
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/CategoryImagesActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
similarity index 57%
rename from app/src/androidTest/java/fr/free/nrw/commons/CategoryImagesActivityTest.kt
rename to app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
index 3b51cd85b..3f6487e47 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/CategoryImagesActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
@@ -1,19 +1,19 @@
 package fr.free.nrw.commons
 
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.category.CategoryImagesActivity
+import fr.free.nrw.commons.review.ReviewActivity
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
 @RunWith(AndroidJUnit4::class)
-class CategoryImagesActivityTest {
+class ReviewActivityTest {
     @get:Rule
-    var activityRule = ActivityTestRule(CategoryImagesActivity::class.java)
+    var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java)
 
     @Test
     fun orientationChange() {
         UITestHelper.changeOrientation(activityRule)
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
index 5e58fca9b..69ce412b9 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
@@ -1,19 +1,59 @@
 package fr.free.nrw.commons
 
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.explore.SearchActivity
+import org.hamcrest.Matchers
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
-import fr.free.nrw.commons.explore.SearchActivity
 
 @RunWith(AndroidJUnit4::class)
 class SearchActivityTest {
     @get:Rule
     var activityRule = ActivityTestRule(SearchActivity::class.java)
 
-    @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
+    private val device: UiDevice =
+        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Before
+    fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
     }
-}
\ No newline at end of file
+
+    @Test
+    fun exploreActivityTest() {
+        val searchAutoComplete =
+            Espresso.onView(
+                Matchers.allOf(
+                    UITestHelper.childAtPosition(
+                        Matchers.allOf(
+                            ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
+                            UITestHelper.childAtPosition(
+                                ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
+                                1,
+                            ),
+                        ),
+                        0,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            )
+        searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard())
+        UITestHelper.sleep(5000)
+        device.swipe(1000, 1400, 500, 1400, 20)
+        device.swipe(800, 1400, 600, 1400, 20)
+        device.swipe(800, 1400, 600, 1400, 20)
+        device.swipe(800, 1400, 600, 1400, 20)
+        device.swipe(800, 1400, 600, 1400, 20)
+        device.swipe(800, 1400, 600, 1400, 20)
+        UITestHelper.sleep(1000)
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt
new file mode 100644
index 000000000..ec132b447
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt
@@ -0,0 +1,65 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.ActivityTestRule
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.settings.SettingsActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SettingsActivityLoggedInTest {
+    @get:Rule
+    var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
+
+    private val device: UiDevice =
+        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Before
+    fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
+        UITestHelper.loginUser()
+        UITestHelper.skipWelcome()
+        Intents
+            .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+    }
+
+    @Test
+    fun testSettings() {
+        Espresso
+            .onView(
+                Matchers.allOf(
+                    ViewMatchers.withContentDescription("More"),
+                    UITestHelper.childAtPosition(
+                        UITestHelper.childAtPosition(
+                            ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        4,
+                    ),
+                    ViewMatchers.isDisplayed(),
+                ),
+            ).perform(ViewActions.click())
+        Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform(
+            ViewActions.scrollTo(),
+            ViewActions.click(),
+        )
+        Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name))
+        UITestHelper.sleep(1000)
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
index 6d1b5f748..c5a91cd56 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
@@ -1,28 +1,26 @@
 package fr.free.nrw.commons
 
+import androidx.recyclerview.widget.RecyclerView
 import androidx.test.espresso.Espresso
 import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.action.ViewActions.replaceText
 import androidx.test.espresso.assertion.ViewAssertions.matches
-import androidx.test.espresso.matcher.PreferenceMatchers
-import androidx.test.espresso.matcher.ViewMatchers.*
-import androidx.test.filters.LargeTest
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.matcher.ViewMatchers.isEnabled
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
 import com.google.gson.Gson
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
 import fr.free.nrw.commons.kvstore.JsonKvStore
-import fr.free.nrw.commons.settings.Prefs
 import fr.free.nrw.commons.settings.SettingsActivity
-import org.hamcrest.Matchers.allOf
-import org.hamcrest.core.IsNot.not
-import org.junit.Assert.assertEquals
+import org.hamcrest.CoreMatchers.allOf
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
-@LargeTest
 @RunWith(AndroidJUnit4::class)
 class SettingsActivityTest {
     private lateinit var defaultKvStore: JsonKvStore
@@ -30,125 +28,39 @@ class SettingsActivityTest {
     @get:Rule
     var activityRule: ActivityTestRule<*> = ActivityTestRule(SettingsActivity::class.java)
 
+    private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
     @Before
     fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
         val context = InstrumentationRegistry.getInstrumentation().targetContext
         val storeName = context.packageName + "_preferences"
         defaultKvStore = JsonKvStore(context, storeName, Gson())
     }
 
-    @Test
-    fun setRecentUploadLimitTo123() {
-        // Open "Use external storage" preference
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .perform(click())
-
-        // Try setting it to 123
-        Espresso.onView(withId(android.R.id.edit))
-                .perform(replaceText("123"))
-
-        // Click "OK"
-        Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
-                .perform(click())
-
-        // Check setting set to 123 in SharedPreferences
-        assertEquals(
-                123,
-                defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
-        )
-
-        // Check displaying 123 in summary text
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .onChildView(withId(android.R.id.summary))
-                .check(matches(withText("123")))
-    }
-
-    @Test
-    fun setRecentUploadLimitTo0() {
-        // Open "Use external storage" preference
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .perform(click())
-
-        // Try setting it to 0
-        Espresso.onView(withId(android.R.id.edit))
-                .perform(replaceText("0"))
-
-        // Click "OK"
-        Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
-                .perform(click())
-
-        // Check setting set to 100 in SharedPreferences
-        assertEquals(
-                100,
-                defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
-        )
-
-        // Check displaying 100 in summary text
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .onChildView(withId(android.R.id.summary))
-                .check(matches(withText("100")))
-    }
-
-    @Test
-    fun setRecentUploadLimitTo700() {
-        // Open "Use external storage" preference
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .perform(click())
-
-        // Try setting it to 700
-        Espresso.onView(withId(android.R.id.edit))
-                .perform(replaceText("700"))
-
-        // Click "OK"
-        Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
-                .perform(click())
-
-        // Check setting set to 500 in SharedPreferences
-        assertEquals(
-                500,
-                defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
-        )
-
-        // Check displaying 100 in summary text
-        Espresso.onData(PreferenceMatchers.withKey("uploads"))
-                .inAdapterView(withId(android.R.id.list))
-                .onChildView(withId(android.R.id.summary))
-                .check(matches(withText("500")))
-    }
-
     @Test
     fun useAuthorNameTogglesOn() {
         // Turn on "Use author name" preference if currently off
         if (!defaultKvStore.getBoolean("useAuthorName", false)) {
-            Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
-                    .inAdapterView(withId(android.R.id.list))
-                    .perform(click())
+            Espresso
+                .onView(
+                    allOf(
+                        withId(R.id.recycler_view),
+                        childAtPosition(withId(android.R.id.list_container), 0),
+                    ),
+                ).perform(
+                    RecyclerViewActions.actionOnItemAtPosition(6, click()),
+                )
         }
-
         // Check authorName preference is enabled
-        Espresso.onData(PreferenceMatchers.withKey("authorName"))
-                .inAdapterView(withId(android.R.id.list))
-                .check(matches(isEnabled()))
-    }
-
-    @Test
-    fun useAuthorNameTogglesOff() {
-        // Turn off "Use external storage" preference if currently on
-        if (defaultKvStore.getBoolean("useAuthorName", false)) {
-            Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
-                    .inAdapterView(withId(android.R.id.list))
-                    .perform(click())
-        }
-
-        // Check authorName preference is enabled
-        Espresso.onData(PreferenceMatchers.withKey("authorName"))
-                .inAdapterView(withId(android.R.id.list))
-                .check(matches(not(isEnabled())))
+        Espresso
+            .onView(
+                allOf(
+                    withId(R.id.recycler_view),
+                    childAtPosition(withId(android.R.id.list_container), 0),
+                ),
+            ).check(matches(isEnabled()))
     }
 
     @Test
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt
deleted file mode 100644
index 534e6b27f..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.assertion.ViewAssertions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.intent.Intents.intended
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
-import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.auth.SignupActivity
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class SignupTest {
-    @get:Rule
-    var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
-
-    @Before
-    fun setup() {
-        UITestHelper.skipWelcome()
-    }
-
-    @Test
-    fun testSignupButton() {
-        try {
-            Intents.init()
-        } catch (ex: IllegalStateException) {
-
-        }
-
-        UITestHelper.sleep(3000)
-        Espresso.onView(withId(R.id.sign_up_button))
-                .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
-                .perform(click())
-        intended(hasComponent(SignupActivity::class.java.name))
-        Intents.release()
-    }
-
-    @Test
-    fun orientationChange() {
-        UITestHelper.changeOrientation(activityRule)
-    }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
index f5716fea4..ebb06e4af 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
@@ -2,7 +2,8 @@ package fr.free.nrw.commons
 
 import android.app.Activity
 import android.content.pm.ActivityInfo
-import androidx.test.espresso.Espresso.closeSoftKeyboard
+import android.view.View
+import android.view.ViewGroup
 import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.NoMatchingViewException
 import androidx.test.espresso.action.ViewActions
@@ -12,36 +13,141 @@ import org.apache.commons.lang3.StringUtils
 import org.hamcrest.BaseMatcher
 import org.hamcrest.Description
 import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.hamcrest.TypeSafeMatcher
 import timber.log.Timber
 
-
 class UITestHelper {
     companion object {
         fun skipWelcome() {
             try {
-                //Skip tutorial
+                onView(ViewMatchers.withId(R.id.button_ok))
+                    .perform(ViewActions.click())
+                // Skip tutorial
                 onView(ViewMatchers.withId(R.id.finishTutorialButton))
-                        .perform(ViewActions.click())
+                    .perform(ViewActions.click())
+            } catch (ignored: NoMatchingViewException) {
+            }
+        }
+
+        fun skipLogin() {
+            try {
+                // Skip Login
+                val htmlTextView =
+                    onView(
+                        Matchers.allOf(
+                            ViewMatchers.withId(R.id.skip_login),
+                            ViewMatchers.withText("Skip"),
+                            ViewMatchers.isDisplayed(),
+                        ),
+                    )
+                htmlTextView.perform(ViewActions.click())
+
+                val appCompatButton =
+                    onView(
+                        Matchers.allOf(
+                            ViewMatchers.withId(android.R.id.button1),
+                            ViewMatchers.withText("Yes"),
+                            childAtPosition(
+                                childAtPosition(
+                                    ViewMatchers.withId(R.id.buttonPanel),
+                                    0,
+                                ),
+                                3,
+                            ),
+                        ),
+                    )
+                appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click())
             } catch (ignored: NoMatchingViewException) {
             }
         }
 
         fun loginUser() {
             try {
-                //Perform Login
+                // Perform Login
                 sleep(3000)
                 onView(ViewMatchers.withId(R.id.login_username))
-                        .perform(ViewActions.clearText(), ViewActions.typeText(getTestUsername()))
-                closeSoftKeyboard()
+                    .perform(
+                        ViewActions.replaceText(getTestUsername()),
+                        ViewActions.closeSoftKeyboard(),
+                    )
+                sleep(2000)
                 onView(ViewMatchers.withId(R.id.login_password))
-                        .perform(ViewActions.clearText(), ViewActions.typeText(getTestUserPassword()))
-                closeSoftKeyboard()
+                    .perform(
+                        ViewActions.replaceText(getTestUserPassword()),
+                        ViewActions.closeSoftKeyboard(),
+                    )
+                sleep(2000)
                 onView(ViewMatchers.withId(R.id.login_button))
-                        .perform(ViewActions.click())
+                    .perform(ViewActions.click())
                 sleep(10000)
             } catch (ignored: NoMatchingViewException) {
             }
+        }
 
+        fun logoutUser() {
+            try {
+                onView(
+                    Matchers.allOf(
+                        ViewMatchers.withContentDescription("More"),
+                        childAtPosition(
+                            childAtPosition(
+                                ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+                                0,
+                            ),
+                            4,
+                        ),
+                        ViewMatchers.isDisplayed(),
+                    ),
+                ).perform(ViewActions.click())
+                onView(
+                    Matchers.allOf(
+                        ViewMatchers.withId(R.id.more_logout),
+                        ViewMatchers.withText("Logout"),
+                        childAtPosition(
+                            childAtPosition(
+                                ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet),
+                                0,
+                            ),
+                            6,
+                        ),
+                    ),
+                ).perform(ViewActions.scrollTo(), ViewActions.click())
+                onView(
+                    Matchers.allOf(
+                        ViewMatchers.withId(android.R.id.button1),
+                        ViewMatchers.withText("Yes"),
+                        childAtPosition(
+                            childAtPosition(
+                                ViewMatchers.withId(R.id.buttonPanel),
+                                0,
+                            ),
+                            3,
+                        ),
+                    ),
+                ).perform(ViewActions.scrollTo(), ViewActions.click())
+                sleep(5000)
+            } catch (ignored: NoMatchingViewException) {
+            }
+        }
+
+        fun childAtPosition(
+            parentMatcher: Matcher,
+            position: Int,
+        ): Matcher {
+            return object : TypeSafeMatcher() {
+                override fun describeTo(description: Description) {
+                    description.appendText("Child at position $position in parent ")
+                    parentMatcher.describeTo(description)
+                }
+
+                public override fun matchesSafely(view: View): Boolean {
+                    val parent = view.parent
+                    return parent is ViewGroup &&
+                        parentMatcher.matches(parent) &&
+                        view == parent.getChildAt(position)
+                }
+            }
         }
 
         fun sleep(timeInMillis: Long) {
@@ -57,16 +163,21 @@ class UITestHelper {
             val username = BuildConfig.TEST_USERNAME
             if (StringUtils.isEmpty(username) || username == "null") {
                 throw NotImplementedError("Configure your beta account's username")
-            } else return username
+            } else {
+                return username
+            }
         }
 
         private fun getTestUserPassword(): String {
             val password = BuildConfig.TEST_PASSWORD
             if (StringUtils.isEmpty(password) || password == "null") {
                 throw NotImplementedError("Configure your beta account's password")
-            } else return password
+            } else {
+                return password
+            }
         }
-        fun  changeOrientation(activityRule: ActivityTestRule){
+
+        fun  changeOrientation(activityRule: ActivityTestRule) {
             activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
             assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
             activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
@@ -76,6 +187,7 @@ class UITestHelper {
         fun  first(matcher: Matcher): Matcher? {
             return object : BaseMatcher() {
                 var isFirst = true
+
                 override fun matches(item: Any): Boolean {
                     if (isFirst && matcher.matches(item)) {
                         isFirst = false
@@ -90,4 +202,4 @@ class UITestHelper {
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
index b7f89e54e..d3a814f2d 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
@@ -1,7 +1,7 @@
 package fr.free.nrw.commons
 
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
 import fr.free.nrw.commons.upload.UploadActivity
 import org.junit.Rule
 import org.junit.Test
@@ -16,4 +16,4 @@ class UploadActivityTest {
     fun orientationChange() {
         UITestHelper.changeOrientation(activityRule)
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt
new file mode 100644
index 000000000..ed57709fc
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt
@@ -0,0 +1,203 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
+import androidx.test.espresso.action.ViewActions.replaceText
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.ActivityTestRule
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.locationpicker.LocationPickerActivity
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers.allOf
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UploadCancelledTest {
+    @Rule
+    @JvmField
+    var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
+
+    @Rule
+    @JvmField
+    var mGrantPermissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(
+            "android.permission.WRITE_EXTERNAL_STORAGE",
+        )
+
+    private val device: UiDevice =
+        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Before
+    fun setup() {
+        try {
+            Intents.init()
+        } catch (_: IllegalStateException) {
+        }
+        device.unfreezeRotation()
+        device.setOrientationNatural()
+        device.freezeRotation()
+        UITestHelper.loginUser()
+        UITestHelper.skipWelcome()
+        Intents
+            .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+    }
+
+    @After
+    fun teardown() {
+        try {
+            Intents.release()
+        } catch (_: IllegalStateException) {
+        }
+    }
+
+    @Test
+    fun uploadCancelledAfterLocationPickedTest() {
+        val bottomNavigationItemView =
+            onView(
+                allOf(
+                    childAtPosition(
+                        childAtPosition(
+                            withId(R.id.fragment_main_nav_tab_layout),
+                            0,
+                        ),
+                        1,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        bottomNavigationItemView.perform(click())
+
+        UITestHelper.sleep(12000)
+
+        val actionMenuItemView =
+            onView(
+                allOf(
+                    withId(R.id.list_sheet),
+                    childAtPosition(
+                        childAtPosition(
+                            withId(R.id.toolbar),
+                            1,
+                        ),
+                        0,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        actionMenuItemView.perform(click())
+
+        val recyclerView =
+            onView(
+                allOf(
+                    withId(R.id.rv_nearby_list),
+                ),
+            )
+        recyclerView.perform(
+            RecyclerViewActions.actionOnItemAtPosition(
+                0,
+                click(),
+            ),
+        )
+
+        val linearLayout3 =
+            onView(
+                allOf(
+                    withId(R.id.cameraButton),
+                    childAtPosition(
+                        allOf(
+                            withId(R.id.nearby_button_layout),
+                        ),
+                        0,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        linearLayout3.perform(click())
+
+        val pasteSensitiveTextInputEditText =
+            onView(
+                allOf(
+                    withId(R.id.caption_item_edit_text),
+                    childAtPosition(
+                        childAtPosition(
+                            withId(R.id.caption_item_edit_text_input_layout),
+                            0,
+                        ),
+                        0,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard())
+
+        val pasteSensitiveTextInputEditText2 =
+            onView(
+                allOf(
+                    withId(R.id.description_item_edit_text),
+                    childAtPosition(
+                        childAtPosition(
+                            withId(R.id.description_item_edit_text_input_layout),
+                            0,
+                        ),
+                        0,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard())
+
+        val appCompatButton2 =
+            onView(
+                allOf(
+                    withId(R.id.btn_next),
+                    childAtPosition(
+                        childAtPosition(
+                            withId(R.id.ll_container_media_detail),
+                            2,
+                        ),
+                        1,
+                    ),
+                    isDisplayed(),
+                ),
+            )
+        appCompatButton2.perform(click())
+
+        val appCompatButton3 =
+            onView(
+                allOf(
+                    withId(android.R.id.button1),
+                ),
+            )
+        appCompatButton3.perform(scrollTo(), click())
+
+        Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name))
+
+        val floatingActionButton3 =
+            onView(
+                allOf(
+                    withId(R.id.location_chosen_button),
+                    isDisplayed(),
+                ),
+            )
+        UITestHelper.sleep(2000)
+        floatingActionButton3.perform(click())
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
index e52acb4d0..048d540b7 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
@@ -8,7 +8,6 @@ import android.graphics.Bitmap
 import android.net.Uri
 import android.os.Environment
 import android.view.View
-import androidx.recyclerview.widget.RecyclerView
 import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.NoMatchingViewException
 import androidx.test.espresso.action.ViewActions.click
@@ -20,18 +19,22 @@ import androidx.test.espresso.intent.Intents.intended
 import androidx.test.espresso.intent.Intents.intending
 import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
 import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
-import androidx.test.espresso.matcher.ViewMatchers.*
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withParent
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.rule.ActivityTestRule
 import androidx.test.rule.GrantPermissionRule
-import androidx.test.runner.AndroidJUnit4
 import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.upload.DescriptionsAdapter
+import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
 import fr.free.nrw.commons.util.MyViewAction
 import fr.free.nrw.commons.utils.ConfigUtils
 import org.hamcrest.core.AllOf.allOf
 import org.junit.After
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -40,14 +43,18 @@ import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
 import java.text.SimpleDateFormat
-import java.util.*
+import java.util.Date
+import java.util.Random
 
 @LargeTest
 @RunWith(AndroidJUnit4::class)
 class UploadTest {
     @get:Rule
-    var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE,
-            Manifest.permission.ACCESS_FINE_LOCATION)!!
+    var permissionRule =
+        GrantPermissionRule.grant(
+            Manifest.permission.WRITE_EXTERNAL_STORAGE,
+            Manifest.permission.ACCESS_FINE_LOCATION,
+        )!!
 
     @get:Rule
     var activityRule = ActivityTestRule(LoginActivity::class.java)
@@ -64,11 +71,10 @@ class UploadTest {
     fun setup() {
         try {
             Intents.init()
-        } catch (ex: IllegalStateException) {
-
+        } catch (_: IllegalStateException) {
         }
-        UITestHelper.skipWelcome()
         UITestHelper.loginUser()
+        UITestHelper.skipWelcome()
     }
 
     @After
@@ -77,8 +83,9 @@ class UploadTest {
     }
 
     @Test
+    @Ignore("Fix Failing Test")
     fun testUploadWithDescription() {
-        if (!ConfigUtils.isBetaFlavour()) {
+        if (!ConfigUtils.isBetaFlavour) {
             throw Error("This test should only be run in Beta!")
         }
 
@@ -96,15 +103,14 @@ class UploadTest {
         // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
         dismissWarning("Yes")
 
-        onView(allOf(isDisplayed(), withId(R.id.et_title)))
-                .perform(replaceText(commonsFileName))
+        onView(allOf(isDisplayed(), withId(R.id.tv_title)))
+            .perform(replaceText(commonsFileName))
 
         onView(allOf(isDisplayed(), withId(R.id.description_item_edit_text)))
-                .perform(replaceText(commonsFileName))
-
+            .perform(replaceText(commonsFileName))
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(5000)
         dismissWarning("Yes")
@@ -112,29 +118,30 @@ class UploadTest {
         UITestHelper.sleep(3000)
 
         onView(allOf(isDisplayed(), withId(R.id.et_search)))
-                .perform(replaceText("Uploaded with Mobile/Android Tests"))
+            .perform(replaceText("Uploaded with Mobile/Android Tests"))
 
         UITestHelper.sleep(3000)
 
         try {
             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
-                    .perform(click())
+                .perform(click())
         } catch (ignored: NoMatchingViewException) {
         }
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         dismissWarning("Yes, Submit")
 
         UITestHelper.sleep(500)
 
         onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(10000)
 
-        val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+        val fileUrl =
+            "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
                 commonsFileName.replace(' ', '_') + ".jpg"
         Timber.i("File should be uploaded to $fileUrl")
     }
@@ -142,15 +149,16 @@ class UploadTest {
     private fun dismissWarning(warningText: String) {
         try {
             onView(withText(warningText))
-                    .check(matches(isDisplayed()))
-                    .perform(click())
+                .check(matches(isDisplayed()))
+                .perform(click())
         } catch (ignored: NoMatchingViewException) {
         }
     }
 
     @Test
+    @Ignore("Fix Failing Test")
     fun testUploadWithoutDescription() {
-        if (!ConfigUtils.isBetaFlavour()) {
+        if (!ConfigUtils.isBetaFlavour) {
             throw Error("This test should only be run in Beta!")
         }
 
@@ -168,11 +176,11 @@ class UploadTest {
         // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
         dismissWarning("Yes")
 
-        onView(allOf(isDisplayed(), withId(R.id.et_title)))
-                .perform(replaceText(commonsFileName))
+        onView(allOf(isDisplayed(), withId(R.id.tv_title)))
+            .perform(replaceText(commonsFileName))
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(10000)
         dismissWarning("Yes")
@@ -180,36 +188,38 @@ class UploadTest {
         UITestHelper.sleep(3000)
 
         onView(allOf(isDisplayed(), withId(R.id.et_search)))
-                .perform(replaceText("Test"))
+            .perform(replaceText("Test"))
 
         UITestHelper.sleep(3000)
 
         try {
             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
-                    .perform(click())
+                .perform(click())
         } catch (ignored: NoMatchingViewException) {
         }
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         dismissWarning("Yes, Submit")
 
         UITestHelper.sleep(500)
 
         onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(10000)
 
-        val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+        val fileUrl =
+            "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
                 commonsFileName.replace(' ', '_') + ".jpg"
         Timber.i("File should be uploaded to $fileUrl")
     }
 
     @Test
+    @Ignore("Fix Failing Test")
     fun testUploadWithMultilingualDescription() {
-        if (!ConfigUtils.isBetaFlavour()) {
+        if (!ConfigUtils.isBetaFlavour) {
             throw Error("This test should only be run in Beta!")
         }
 
@@ -227,29 +237,30 @@ class UploadTest {
         // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
         dismissWarningDialog()
 
-        onView(allOf(isDisplayed(), withId(R.id.et_title)))
-                .perform(replaceText(commonsFileName))
+        onView(allOf(isDisplayed(), withId(R.id.tv_title)))
+            .perform(replaceText(commonsFileName))
 
         onView(withId(R.id.rv_descriptions)).perform(
-                RecyclerViewActions
-                        .actionOnItemAtPosition(0,
-                                MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description")))
+            RecyclerViewActions
+                .actionOnItemAtPosition(
+                    0,
+                    MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"),
+                ),
+        )
 
-        onView(withId(R.id.btn_add_description))
-                .perform(click())
+        onView(withId(R.id.btn_add))
+            .perform(click())
 
         onView(withId(R.id.rv_descriptions)).perform(
-                RecyclerViewActions
-                        .actionOnItemAtPosition(1,
-                                MyViewAction.selectSpinnerItemInChildViewWithId(R.id.spinner_description_languages, 2)))
-
-        onView(withId(R.id.rv_descriptions)).perform(
-                RecyclerViewActions
-                        .actionOnItemAtPosition(1,
-                                MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description")))
+            RecyclerViewActions
+                .actionOnItemAtPosition(
+                    1,
+                    MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"),
+                ),
+        )
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(5000)
         dismissWarning("Yes")
@@ -257,29 +268,30 @@ class UploadTest {
         UITestHelper.sleep(3000)
 
         onView(allOf(isDisplayed(), withId(R.id.et_search)))
-                .perform(replaceText("Test"))
+            .perform(replaceText("Test"))
 
         UITestHelper.sleep(3000)
 
         try {
             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
-                    .perform(click())
+                .perform(click())
         } catch (ignored: NoMatchingViewException) {
         }
 
         onView(allOf(isDisplayed(), withId(R.id.btn_next)))
-                .perform(click())
+            .perform(click())
 
         dismissWarning("Yes, Submit")
 
         UITestHelper.sleep(500)
 
         onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
-                .perform(click())
+            .perform(click())
 
         UITestHelper.sleep(10000)
 
-        val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+        val fileUrl =
+            "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
                 commonsFileName.replace(' ', '_') + ".jpg"
         Timber.i("File should be uploaded to $fileUrl")
     }
@@ -312,7 +324,6 @@ class UploadTest {
             } catch (e: IOException) {
                 e.printStackTrace()
             }
-
         }
     }
 
@@ -334,8 +345,8 @@ class UploadTest {
     private fun dismissWarningDialog() {
         try {
             onView(withText("Yes"))
-                    .check(matches(isDisplayed()))
-                    .perform(click())
+                .check(matches(isDisplayed()))
+                .perform(click())
         } catch (ignored: NoMatchingViewException) {
         }
     }
@@ -343,10 +354,10 @@ class UploadTest {
     private fun openGallery() {
         // Open FAB
         onView(allOf(withId(R.id.fab_plus), isDisplayed()))
-                .perform(click())
+            .perform(click())
 
         // Click gallery
         onView(allOf(withId(R.id.fab_gallery), isDisplayed()))
-                .perform(click())
+            .perform(click())
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
index 3716a86e5..5956b3c02 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
@@ -5,15 +5,20 @@ import androidx.test.espresso.action.ViewActions
 import androidx.test.espresso.assertion.ViewAssertions.matches
 import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
 import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
 import androidx.viewpager.widget.ViewPager
 import fr.free.nrw.commons.utils.ConfigUtils
 import org.hamcrest.core.IsNot.not
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.CoreMatchers.equalTo
 
 @LargeTest
 @RunWith(AndroidJUnit4::class)
@@ -21,86 +26,108 @@ class WelcomeActivityTest {
     @get:Rule
     var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java)
 
+    private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Before
+    fun setup() {
+        device.setOrientationNatural()
+        device.freezeRotation()
+    }
+
     @Test
     fun ifBetaShowsSkipButton() {
-        if (ConfigUtils.isBetaFlavour()) {
+        if (ConfigUtils.isBetaFlavour) {
+            onView(withId(R.id.button_ok))
+                .perform(ViewActions.click())
             onView(withId(R.id.finishTutorialButton))
-                    .check(matches(isDisplayed()))
+                .check(matches(isDisplayed()))
         }
     }
 
     @Test
     fun ifProdHidesSkipButton() {
-        if (!ConfigUtils.isBetaFlavour()) {
+        if (!ConfigUtils.isBetaFlavour) {
+            onView(withId(R.id.button_ok))
+                .perform(ViewActions.click())
             onView(withId(R.id.finishTutorialButton))
-                    .check(matches(not(isDisplayed())))
+                .check(matches(not(isDisplayed())))
         }
     }
 
     @Test
     fun testBetaSkipButton() {
-        if (ConfigUtils.isBetaFlavour()) {
+        if (ConfigUtils.isBetaFlavour) {
+            onView(withId(R.id.button_ok))
+                .perform(ViewActions.click())
             onView(withId(R.id.finishTutorialButton))
-                    .perform(ViewActions.click())
-            assert(activityRule.activity.isDestroyed)
+                .perform(ViewActions.click())
+            assertThat(activityRule.activity.isDestroyed, equalTo(true))
         }
     }
 
     @Test
     fun testSwipingOnce() {
+        onView(withId(R.id.button_ok))
+            .perform(ViewActions.click())
         onView(withId(R.id.welcomePager))
-                .perform(ViewActions.swipeLeft())
-        assert(true)
+            .perform(ViewActions.swipeLeft())
+        assertThat(true, equalTo(true))
         onView(withId(R.id.welcomePager))
-                .perform(ViewActions.swipeRight())
-        assert(true)
+            .perform(ViewActions.swipeRight())
+        assertThat(true, equalTo(true))
     }
 
     @Test
     fun testSwipingWholeTutorial() {
+        onView(withId(R.id.button_ok))
+            .perform(ViewActions.click())
         onView(withId(R.id.welcomePager))
-                .perform(ViewActions.swipeLeft())
-                .perform(ViewActions.swipeLeft())
-                .perform(ViewActions.swipeLeft())
-                .perform(ViewActions.swipeLeft())
-        assert(true)
+            .perform(ViewActions.swipeLeft())
+            .perform(ViewActions.swipeLeft())
+            .perform(ViewActions.swipeLeft())
+            .perform(ViewActions.swipeLeft())
+        assertThat(true, equalTo(true))
         onView(withId(R.id.welcomePager))
-                .perform(ViewActions.swipeRight())
-                .perform(ViewActions.swipeRight())
-                .perform(ViewActions.swipeRight())
-                .perform(ViewActions.swipeRight())
-        assert(true)
+            .perform(ViewActions.swipeRight())
+            .perform(ViewActions.swipeRight())
+            .perform(ViewActions.swipeRight())
+            .perform(ViewActions.swipeRight())
+        assertThat(true, equalTo(true))
     }
 
     @Test
-    fun swipeBeyondBounds(){
-            var  view_pager=activityRule.activity.findViewById(R.id.welcomePager)
+    fun swipeBeyondBounds() {
+        val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
 
-            view_pager.adapter?.let {  view_pager.currentItem == view_pager.adapter?.count?.minus(1)
-                if (view_pager.currentItem==3){
-                    onView(withId(R.id.welcomePager))
-                            .perform(ViewActions.swipeLeft())
-                    assert(true)
-                    onView(withId(R.id.welcomePager))
-                            .perform(ViewActions.swipeRight())
-                    assert(false)
-                }}
+        viewPager.adapter?.let {
+            if (viewPager.currentItem == 3) {
+                onView(withId(R.id.welcomePager))
+                    .perform(ViewActions.swipeLeft())
+                assertThat(true, equalTo(true))
+                onView(withId(R.id.welcomePager))
+                    .perform(ViewActions.swipeRight())
+                assertThat(true, equalTo(true))
+            }
+        }
     }
 
     @Test
-    fun swipeTillLastAndFinish(){
-            var  view_pager=activityRule.activity.findViewById(R.id.welcomePager)
+    fun swipeTillLastAndFinish() {
+        val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
 
-            view_pager.adapter?.let {  view_pager.currentItem == view_pager.adapter?.count?.minus(1)
-                if (view_pager.currentItem==3){
-                    onView(withId(R.id.finishTutorialButton))
-                            .perform(ViewActions.click())
-                    assert(activityRule.activity.isDestroyed)
-                }}
+        viewPager.adapter?.let {
+            if (viewPager.currentItem == 3) {
+                onView(withId(R.id.button_ok))
+                    .perform(ViewActions.click())
+                onView(withId(R.id.finishTutorialButton))
+                    .perform(ViewActions.click())
+                assertThat(activityRule.activity.isDestroyed, equalTo(true))
+            }
+        }
     }
 
     @Test
     fun orientationChange() {
         UITestHelper.changeOrientation(activityRule)
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt b/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt
new file mode 100644
index 000000000..54228bc13
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt
@@ -0,0 +1,271 @@
+package fr.free.nrw.commons.contributions
+
+import android.content.res.Configuration
+import android.os.Looper
+import androidx.fragment.app.testing.FragmentScenario
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.lifecycle.Lifecycle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.OkHttpConnectionFactory
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.TestCommonsApplication
+import fr.free.nrw.commons.createTestClient
+import fr.free.nrw.commons.upload.WikidataPlace
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import java.lang.reflect.Method
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [21], application = TestCommonsApplication::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+class ContributionsListFragmentUnitTests {
+    private lateinit var scenario: FragmentScenario
+    private lateinit var fragment: ContributionsListFragment
+
+    private val adapter: ContributionsListAdapter = mock()
+    private val contribution: Contribution = mock()
+    private val media: Media = mock()
+    private val wikidataPlace: WikidataPlace = mock()
+
+    @Before
+    fun setUp() {
+        OkHttpConnectionFactory.CLIENT = createTestClient()
+
+        scenario =
+            launchFragmentInContainer(
+                initialState = Lifecycle.State.RESUMED,
+                themeResId = R.style.LightAppTheme,
+            ) {
+                ContributionsListFragment()
+                    .apply {
+                        contributionsListPresenter = mock()
+                        callback = mock()
+                    }.also {
+                        fragment = it
+                    }
+            }
+
+        scenario.onFragment {
+            it.adapter = adapter
+        }
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun checkFragmentNotNull() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        Assert.assertNotNull(fragment)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnDetach() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.onDetach()
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testGetContributionStateAt() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        `when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution)
+        fragment.getContributionStateAt(0)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnScrollToTop() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.rvContributionsList = mock()
+        fragment.scrollToTop()
+        verify(fragment.rvContributionsList)?.smoothScrollToPosition(0)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnConfirmClicked() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        `when`(contribution.media).thenReturn(media)
+        `when`(media.wikiCode).thenReturn("")
+        `when`(contribution.wikidataPlace).thenReturn(wikidataPlace)
+        fragment.onConfirmClicked(contribution, true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testGetTotalMediaCount() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.totalMediaCount
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testGetMediaAtPositionCaseNonNull() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        `when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution)
+        `when`(contribution.media).thenReturn(media)
+        fragment.getMediaAtPosition(0)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testGetMediaAtPositionCaseNull() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        `when`(adapter.getContributionForPosition(anyInt())).thenReturn(null)
+        fragment.getMediaAtPosition(0)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testShowAddImageToWikipediaInstructions() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "showAddImageToWikipediaInstructions",
+                Contribution::class.java,
+            )
+        method.isAccessible = true
+        method.invoke(fragment, contribution)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testAddImageToWikipedia() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.addImageToWikipedia(contribution)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOpenMediaDetail() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.openMediaDetail(0, true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnViewStateRestored() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.onViewStateRestored(mock())
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnSaveInstanceState() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.onSaveInstanceState(mock())
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testShowNoContributionsUI() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.showNoContributionsUI(true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testShowProgress() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.showProgress(true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testShowWelcomeTip() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        fragment.showWelcomeTip(true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testAnimateFAB() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment {
+            it.requireView().findViewById(R.id.fab_plus).hide()
+        }
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "animateFAB",
+                Boolean::class.java,
+            )
+        method.isAccessible = true
+        method.invoke(fragment, true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testAnimateFABCaseShownAndOpen() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment {
+            it.requireView().findViewById(R.id.fab_plus).show()
+        }
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "animateFAB",
+                Boolean::class.java,
+            )
+        method.isAccessible = true
+        method.invoke(fragment, true)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testAnimateFABCaseShownAndClose() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment {
+            it.requireView().findViewById(R.id.fab_plus).show()
+        }
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "animateFAB",
+                Boolean::class.java,
+            )
+        method.isAccessible = true
+        method.invoke(fragment, false)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testSetListeners() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "setListeners",
+            )
+        method.isAccessible = true
+        method.invoke(fragment)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testInitializeAnimations() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        val method: Method =
+            ContributionsListFragment::class.java.getDeclaredMethod(
+                "initializeAnimations",
+            )
+        method.isAccessible = true
+        method.invoke(fragment)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnConfigurationChanged() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        val newConfig: Configuration = mock()
+        newConfig.orientation = Configuration.ORIENTATION_LANDSCAPE
+        fragment.onConfigurationChanged(newConfig)
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt b/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt
new file mode 100644
index 000000000..c2906b501
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt
@@ -0,0 +1,61 @@
+package fr.free.nrw.commons.navtab
+
+import android.os.Looper
+import androidx.fragment.app.testing.FragmentScenario
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.lifecycle.Lifecycle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.TestCommonsApplication
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [21], application = TestCommonsApplication::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+class MoreBottomSheetLoggedOutFragmentUnitTests {
+    private lateinit var scenario: FragmentScenario
+
+    @Before
+    fun setUp() {
+        scenario =
+            launchFragmentInContainer(
+                initialState = Lifecycle.State.RESUMED,
+                themeResId = R.style.LightAppTheme,
+            ) {
+                MoreBottomSheetLoggedOutFragment()
+            }
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnSettingsClicked() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment { it.onSettingsClicked() }
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnAboutClicked() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment { it.onAboutClicked() }
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnFeedbackClicked() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment { it.onFeedbackClicked() }
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testOnLogoutClicked() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        scenario.onFragment { it.onLogoutClicked() }
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
new file mode 100644
index 000000000..647c5bbda
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
@@ -0,0 +1,46 @@
+package fr.free.nrw.commons.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PasteSensitiveTextInputEditTextTest {
+    private var context: Context? = null
+    private var textView: PasteSensitiveTextInputEditText? = null
+
+    @Before
+    fun setup() {
+        context = ApplicationProvider.getApplicationContext()
+        textView = PasteSensitiveTextInputEditText(context!!)
+    }
+
+    // this test has no real value, just % for test code coverage
+    @Test
+    fun extractFormattingAttributeSet() {
+        val methodExtractFormattingAttribute =
+            textView!!.javaClass.getDeclaredMethod(
+                "extractFormattingAttribute",
+                Context::class.java,
+                AttributeSet::class.java,
+            )
+        methodExtractFormattingAttribute.isAccessible = true
+        methodExtractFormattingAttribute.invoke(textView, context, null)
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun setFormattingAllowed() {
+        val fieldFormattingAllowed = textView!!.javaClass.getDeclaredField("formattingAllowed")
+        fieldFormattingAllowed.isAccessible = true
+        textView!!.setFormattingAllowed(true)
+        Assert.assertTrue(fieldFormattingAllowed.getBoolean(textView))
+        textView!!.setFormattingAllowed(false)
+        Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView))
+    }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
index 955c712b9..52ac18e4d 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
@@ -9,56 +9,58 @@ import org.hamcrest.Matcher
 
 class MyViewAction {
     companion object {
-        fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction {
-            return object : ViewAction {
-                override fun getConstraints(): Matcher? {
-                    return null
-                }
+        fun typeTextInChildViewWithId(
+            id: Int,
+            textToBeTyped: String,
+        ): ViewAction =
+            object : ViewAction {
+                override fun getConstraints(): Matcher? = null
 
-                override fun getDescription(): String {
-                    return "Click on a child view with specified id."
-                }
+                override fun getDescription(): String = "Click on a child view with specified id."
 
-                override fun perform(uiController: UiController, view: View) {
+                override fun perform(
+                    uiController: UiController,
+                    view: View,
+                ) {
                     val v = view.findViewById(id) as EditText
                     v.setText(textToBeTyped)
                 }
             }
-        }
 
-        fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction {
-            return object : ViewAction {
-                override fun getConstraints(): Matcher? {
-                    return null
-                }
+        fun selectSpinnerItemInChildViewWithId(
+            id: Int,
+            position: Int,
+        ): ViewAction =
+            object : ViewAction {
+                override fun getConstraints(): Matcher? = null
 
-                override fun getDescription(): String {
-                    return "Click on a child view with specified id."
-                }
+                override fun getDescription(): String = "Click on a child view with specified id."
 
-                override fun perform(uiController: UiController, view: View) {
+                override fun perform(
+                    uiController: UiController,
+                    view: View,
+                ) {
                     val v = view.findViewById(id) as AppCompatSpinner
                     v.setSelection(position)
                 }
             }
-        }
 
-        fun clickItemWithId(id: Int, position: Int): ViewAction {
-            return object : ViewAction {
-                override fun getConstraints(): Matcher? {
-                    return null
-                }
+        fun clickItemWithId(
+            id: Int,
+            position: Int,
+        ): ViewAction =
+            object : ViewAction {
+                override fun getConstraints(): Matcher? = null
 
-                override fun getDescription(): String {
-                    return "Click on a child view with specified id."
-                }
+                override fun getDescription(): String = "Click on a child view with specified id."
 
-                override fun perform(uiController: UiController, view: View) {
+                override fun perform(
+                    uiController: UiController,
+                    view: View,
+                ) {
                     val v = view.findViewById(id) as View
                     v.performClick()
                 }
             }
-        }
-
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/beta/res/xml/shortcuts.xml b/app/src/beta/res/xml/shortcuts.xml
new file mode 100644
index 000000000..65a51995e
--- /dev/null
+++ b/app/src/beta/res/xml/shortcuts.xml
@@ -0,0 +1,18 @@
+
+
+
+  
+    
+  
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d904a5ecc..17917666d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,218 +1,259 @@
 
 
+  xmlns:tools="http://schemas.android.com/tools">
 
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
 
+  
 
-    
-    
+    
+    
+      
 
-    
+      
 
-        
+      
+    
+    
+    
+   
+  
 
-        
+  
+    
+    
+    
+    
+    
+    
+    
+    
+      
+        
 
-        
-            
-                
+        
+      
 
-                
-            
+      
+    
+    
+    
+      
+        
 
-            
+        
 
-        
-        
+        
+        
+      
+      
+        
 
-        
-            
-                
+        
 
-                
+        
+        
+      
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
 
-                
-                
-            
-            
-                
+    
+      
+        
+      
 
-                
+      
+    
+    
+    
+    
 
-                
-                
-            
-        
-        
-        
-        
+    
+      
+    
+    
+    
+    
+    
+    
 
-        
+    
+      
+        
+      
 
-        
+      
+    
 
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-        
-            
-                
-            
-            
-        
-
-        
-
-        
-            
-        
-
-        
-
-        
-
-        
-
-        
-
-        
-            
-                
-            
-
-            
-        
-
-        
-
-    
+    
+  
 
 
diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
deleted file mode 100644
index 2c7efa308..000000000
--- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
+++ /dev/null
@@ -1,160 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.annotation.SuppressLint;
-import android.app.AlertDialog;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.SpannableString;
-import android.text.style.UnderlineSpan;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.ArrayAdapter;
-import android.widget.LinearLayout;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import org.wikipedia.util.StringUtil;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.ui.widget.HtmlTextView;
-import fr.free.nrw.commons.utils.ConfigUtils;
-
-/**
- * Represents about screen of this app
- */
-public class AboutActivity extends NavigationBaseActivity {
-    @BindView(R.id.about_version) TextView versionText;
-    @BindView(R.id.about_license) HtmlTextView aboutLicenseText;
-    @BindView(R.id.about_faq) TextView faqText;
-    @BindView(R.id.about_improve) HtmlTextView improve;
-    @BindView(R.id.about_rate_us) TextView rateUsText;
-    @BindView(R.id.about_privacy_policy) TextView privacyPolicyText;
-    @BindView(R.id.about_translate) TextView translateText;
-    @BindView(R.id.about_credits) TextView creditsText;
-    /**
-     * This method helps in the creation About screen
-     *
-     * @param savedInstanceState Data bundle
-     */
-    @Override
-    @SuppressLint("StringFormatInvalid")
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_about);
-
-        ButterKnife.bind(this);
-
-        String aboutText = getString(R.string.about_license);
-        aboutLicenseText.setHtmlText(aboutText);
-
-        @SuppressLint("StringFormatMatches")
-        String improveText = String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL);
-        improve.setHtmlText(improveText);
-
-        versionText.setText(ConfigUtils.getVersionNameWithSha(getApplicationContext()));
-
-        Utils.setUnderlinedText(faqText, R.string.about_faq, getApplicationContext());
-        Utils.setUnderlinedText(rateUsText, R.string.about_rate_us, getApplicationContext());
-        Utils.setUnderlinedText(privacyPolicyText, R.string.about_privacy_policy, getApplicationContext());
-        Utils.setUnderlinedText(translateText, R.string.about_translate, getApplicationContext());
-        Utils.setUnderlinedText(creditsText, R.string.about_credits, getApplicationContext());
-
-        initDrawer();
-    }
-
-    @OnClick(R.id.facebook_launch_icon)
-    public void launchFacebook(View view) {
-        Intent intent;
-        try {
-            intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL));
-            intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME);
-            startActivity(intent);
-        } catch (Exception e) {
-            Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL));
-        }
-    }
-
-    @OnClick(R.id.github_launch_icon)
-    public void launchGithub(View view) {
-        Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
-    }
-
-    @OnClick(R.id.website_launch_icon)
-    public void launchWebsite(View view) {
-        Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL));
-    }
-
-    @OnClick(R.id.about_rate_us)
-    public void launchRatings(View view){
-        Utils.rateApp(this);
-    }
-
-    @OnClick(R.id.about_credits)
-    public void launchCredits(View view) {
-        Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL));
-    }
-
-    @OnClick(R.id.about_privacy_policy)
-    public void launchPrivacyPolicy(View view) {
-        Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
-    }
-
-
-    @OnClick(R.id.about_faq)
-    public void launchFrequentlyAskedQuesions(View view) {
-        Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL));
-    }
-
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        MenuInflater inflater = getMenuInflater();
-        inflater.inflate(R.menu.menu_about, menu);
-        return super.onCreateOptionsMenu(menu);
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.share_app_icon:
-                String shareText = String.format(getString(R.string.share_text), Urls.PLAY_STORE_URL_PREFIX + this.getPackageName());
-                Intent sendIntent = new Intent();
-                sendIntent.setAction(Intent.ACTION_SEND);
-                sendIntent.putExtra(Intent.EXTRA_TEXT, shareText);
-                sendIntent.setType("text/plain");
-                startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via)));
-                return true;
-            default:
-                return super.onOptionsItemSelected(item);
-        }
-    }
-
-    @OnClick(R.id.about_translate)
-    public void launchTranslate(View view) {
-        final ArrayAdapter languageAdapter = new ArrayAdapter<>(AboutActivity.this,
-                android.R.layout.simple_spinner_dropdown_item, CommonsApplication.getInstance().getLanguageLookUpTable().getLocalizedNames());
-        final Spinner spinner = new Spinner(AboutActivity.this);
-        spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
-        spinner.setAdapter(languageAdapter);
-        spinner.setGravity(17);
-        spinner.setPadding(50,0,0,0);
-
-        AlertDialog.Builder builder = new AlertDialog.Builder(AboutActivity.this);
-        builder.setView(spinner);
-        builder.setTitle(R.string.about_translate_title)
-                .setMessage(R.string.about_translate_message)
-                .setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> {
-                    String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
-                    Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
-                });
-        builder.setNegativeButton(R.string.about_translate_cancel, (dialog, which) -> dialog.cancel());
-        builder.create().show();
-
-    }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
new file mode 100644
index 000000000..865ad3ddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
@@ -0,0 +1,207 @@
+package fr.free.nrw.commons
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.Intent.ACTION_VIEW
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.LinearLayout
+import android.widget.Spinner
+import fr.free.nrw.commons.CommonsApplication.Companion.instance
+import fr.free.nrw.commons.databinding.ActivityAboutBinding
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
+import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
+import java.util.Collections
+import androidx.core.net.toUri
+import fr.free.nrw.commons.utils.applyEdgeToEdgeTopInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import fr.free.nrw.commons.utils.setUnderlinedText
+
+/**
+ * Represents about screen of this app
+ */
+class AboutActivity : BaseActivity() {
+    /*
+         This View Binding class is auto-generated for each xml file. The format is usually the name
+         of the file with PascalCasing (The underscore characters will be ignored).
+         More information is available at https://developer.android.com/topic/libraries/view-binding
+        */
+    private var binding: ActivityAboutBinding? = null
+
+    /**
+     * This method helps in the creation About screen
+     *
+     * @param savedInstanceState Data bundle
+     */
+    @SuppressLint("StringFormatInvalid")  //TODO:
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        /*
+          Instead of just setting the view with the xml file. We need to use View Binding class.
+         */
+        binding = ActivityAboutBinding.inflate(layoutInflater)
+        val view: View = binding!!.root
+        applyEdgeToEdgeTopInsets(binding!!.toolbarLayout)
+        setContentView(view)
+
+        setSupportActionBar(binding!!.toolbarBinding.toolbar)
+        supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+        val aboutText = getString(R.string.about_license)
+        /*
+          We can then access all the views by just using the id names like this.
+          camelCasing is used with underscore characters being ignored.
+         */
+        binding!!.aboutLicense.setHtmlText(aboutText)
+
+        @SuppressLint("StringFormatMatches") // TODO:
+        val improveText =
+            String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL)
+        binding!!.aboutImprove.setHtmlText(improveText)
+        binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha()
+
+        binding!!.aboutFaq.setUnderlinedText(R.string.about_faq)
+        binding!!.aboutRateUs.setUnderlinedText(R.string.about_rate_us)
+        binding!!.aboutUserGuide.setUnderlinedText(R.string.user_guide)
+        binding!!.aboutPrivacyPolicy.setUnderlinedText(R.string.about_privacy_policy)
+        binding!!.aboutTranslate.setUnderlinedText(R.string.about_translate)
+        binding!!.aboutCredits.setUnderlinedText(R.string.about_credits)
+
+        /*
+          To set listeners, we can create a separate method and use lambda syntax.
+        */
+        binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook)
+        binding!!.githubLaunchIcon.setOnClickListener(::launchGithub)
+        binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite)
+        binding!!.aboutRateUs.setOnClickListener(::launchRatings)
+        binding!!.aboutCredits.setOnClickListener(::launchCredits)
+        binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy)
+        binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide)
+        binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions)
+        binding!!.aboutTranslate.setOnClickListener(::launchTranslate)
+    }
+
+    override fun onSupportNavigateUp(): Boolean {
+        onBackPressed()
+        return true
+    }
+
+    fun launchFacebook(view: View?) {
+        val intent: Intent
+        try {
+            intent = Intent(ACTION_VIEW, Urls.FACEBOOK_APP_URL.toUri())
+            intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME)
+            startActivity(intent)
+        } catch (e: Exception) {
+            handleWebUrl(this, Urls.FACEBOOK_WEB_URL.toUri())
+        }
+    }
+
+    fun launchGithub(view: View?) {
+        val intent: Intent
+        try {
+            intent = Intent(ACTION_VIEW, Urls.GITHUB_REPO_URL.toUri())
+            intent.setPackage(Urls.GITHUB_PACKAGE_NAME)
+            startActivity(intent)
+        } catch (e: Exception) {
+            handleWebUrl(this, Urls.GITHUB_REPO_URL.toUri())
+        }
+    }
+
+    fun launchWebsite(view: View?) {
+        handleWebUrl(this, Urls.WEBSITE_URL.toUri())
+    }
+
+    fun launchRatings(view: View?) {
+        try {
+            startActivity(
+                Intent(
+                    ACTION_VIEW,
+                    (Urls.PLAY_STORE_PREFIX + packageName).toUri()
+                )
+            )
+        } catch (_: ActivityNotFoundException) {
+            handleWebUrl(this, (Urls.PLAY_STORE_URL_PREFIX + packageName).toUri())
+        }
+    }
+
+    fun launchCredits(view: View?) {
+        handleWebUrl(this, Urls.CREDITS_URL.toUri())
+    }
+
+    fun launchUserGuide(view: View?) {
+        handleWebUrl(this, Urls.USER_GUIDE_URL.toUri())
+    }
+
+    fun launchPrivacyPolicy(view: View?) {
+        handleWebUrl(this, BuildConfig.PRIVACY_POLICY_URL.toUri())
+    }
+
+    fun launchFrequentlyAskedQuesions(view: View?) {
+        handleWebUrl(this, Urls.FAQ_URL.toUri())
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        val inflater = menuInflater
+        inflater.inflate(R.menu.menu_about, menu)
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        when (item.itemId) {
+            R.id.share_app_icon -> {
+                val shareText = String.format(
+                    getString(R.string.share_text),
+                    Urls.PLAY_STORE_URL_PREFIX + this.packageName
+                )
+                val sendIntent = Intent()
+                sendIntent.setAction(Intent.ACTION_SEND)
+                sendIntent.putExtra(Intent.EXTRA_TEXT, shareText)
+                sendIntent.setType("text/plain")
+                startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via)))
+                return true
+            }
+
+            else -> return super.onOptionsItemSelected(item)
+        }
+    }
+
+    fun launchTranslate(view: View?) {
+        val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames()
+        Collections.sort(sortedLocalizedNamesRef)
+        val languageAdapter = ArrayAdapter(
+            this@AboutActivity,
+            android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef
+        )
+        val spinner = Spinner(this@AboutActivity)
+        spinner.layoutParams =
+            LinearLayout.LayoutParams(
+                LinearLayout.LayoutParams.WRAP_CONTENT,
+                LinearLayout.LayoutParams.WRAP_CONTENT
+            )
+        spinner.adapter = languageAdapter
+        spinner.gravity = 17
+        spinner.setPadding(50, 0, 0, 0)
+
+        val positiveButtonRunnable = Runnable {
+            val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition]
+            handleWebUrl(this@AboutActivity, (Urls.TRANSLATE_WIKI_URL + langCode).toUri())
+        }
+        showAlertDialog(
+            this,
+            getString(R.string.about_translate_title),
+            getString(R.string.about_translate_message),
+            getString(R.string.about_translate_proceed),
+            getString(R.string.about_translate_cancel),
+            positiveButtonRunnable,
+            {},
+            spinner
+        )
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
new file mode 100644
index 000000000..28b01d603
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Place
+
+class BaseMarker {
+    private var _position: LatLng = LatLng(0.0, 0.0, 0f)
+    private var _title: String = ""
+    private var _place: Place = Place()
+    private var _icon: Bitmap? = null
+
+    var position: LatLng
+        get() = _position
+        set(value) {
+            _position = value
+        }
+    var title: String
+        get() = _title
+        set(value) {
+            _title = value
+        }
+
+    var place: Place
+        get() = _place
+        set(value) {
+            _place = value
+        }
+    var icon: Bitmap?
+        get() = _icon
+        set(value) {
+            _icon = value
+        }
+
+    constructor() {
+    }
+
+    fun fromResource(
+        context: Context,
+        drawableResId: Int,
+    ) {
+        val drawable: Drawable = context.resources.getDrawable(drawableResId)
+        icon =
+            if (drawable is BitmapDrawable) {
+                drawable.bitmap
+            } else {
+                val bitmap =
+                    Bitmap.createBitmap(
+                        drawable.intrinsicWidth,
+                        drawable.intrinsicHeight,
+                        Bitmap.Config.ARGB_8888,
+                    )
+                val canvas = Canvas(bitmap)
+                drawable.setBounds(0, 0, canvas.width, canvas.height)
+                drawable.draw(canvas)
+                bitmap
+            }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BasePresenter.java b/app/src/main/java/fr/free/nrw/commons/BasePresenter.java
deleted file mode 100644
index 2aa160520..000000000
--- a/app/src/main/java/fr/free/nrw/commons/BasePresenter.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package fr.free.nrw.commons;
-
-/**
- * Base presenter, enforcing contracts to atach and detach view
- */
-public interface BasePresenter {
-    /**
-     * Until a view is attached, it is open to listen events from the presenter
-     */
-    void onAttachView(T view);
-
-    /**
-     * Detaching a view makes sure that the view no more receives events from the presenter
-     */
-    void onDetachView();
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt b/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt
new file mode 100644
index 000000000..085307c3e
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt
@@ -0,0 +1,10 @@
+package fr.free.nrw.commons
+
+/**
+ * Base presenter, enforcing contracts to attach and detach view
+ */
+interface BasePresenter {
+    fun onAttachView(view: T)
+
+    fun onDetachView()
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
new file mode 100644
index 000000000..c0c0b9a61
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
@@ -0,0 +1,19 @@
+package fr.free.nrw.commons
+
+/**
+ * Production variant related constants which is used in beta variant for some specific GET calls on
+ * production server where beta server does not work
+ */
+object BetaConstants {
+    /**
+     * Commons production URL which is used in beta for some specific GET calls on
+     * production server where beta server does not work
+     */
+    const val COMMONS_URL = "https://commons.wikimedia.org/"
+
+    /**
+     * Commons production's depicts property which is used in beta for some specific GET calls on
+     * production server where beta server does not work
+     */
+    const val DEPICTS_PROPERTY = "P180"
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
new file mode 100644
index 000000000..e3a644c6a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
@@ -0,0 +1,33 @@
+package fr.free.nrw.commons
+
+import android.os.Parcel
+import android.os.Parcelable
+
+class CameraPosition(
+    val latitude: Double,
+    val longitude: Double,
+    val zoom: Double,
+) : Parcelable {
+    constructor(parcel: Parcel) : this(
+        parcel.readDouble(),
+        parcel.readDouble(),
+        parcel.readDouble(),
+    )
+
+    override fun writeToParcel(
+        parcel: Parcel,
+        flags: Int,
+    ) {
+        parcel.writeDouble(latitude)
+        parcel.writeDouble(longitude)
+        parcel.writeDouble(zoom)
+    }
+
+    override fun describeContents(): Int = 0
+
+    companion object CREATOR : Parcelable.Creator {
+        override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel)
+
+        override fun newArray(size: Int): Array = arrayOfNulls(size)
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java b/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
deleted file mode 100644
index 8b6ca47e0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package fr.free.nrw.commons;
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.AppAdapter;
-import org.wikipedia.dataclient.SharedPreferenceCookieManager;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.json.GsonMarshaller;
-import org.wikipedia.json.GsonUnmarshaller;
-import org.wikipedia.login.LoginResult;
-
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import okhttp3.OkHttpClient;
-
-public class CommonsAppAdapter extends AppAdapter {
-    private final int DEFAULT_THUMB_SIZE = 640;
-    private final String COOKIE_STORE_NAME = "cookie_store";
-
-    private final SessionManager sessionManager;
-    private final JsonKvStore preferences;
-
-    CommonsAppAdapter(@NonNull SessionManager sessionManager, @NonNull JsonKvStore preferences) {
-        this.sessionManager = sessionManager;
-        this.preferences = preferences;
-    }
-
-    @Override
-    public String getMediaWikiBaseUrl() {
-        return BuildConfig.COMMONS_URL;
-    }
-
-    @Override
-    public String getRestbaseUriFormat() {
-        return BuildConfig.COMMONS_URL;
-    }
-
-    @Override
-    public OkHttpClient getOkHttpClient(@NonNull WikiSite wikiSite) {
-        return OkHttpConnectionFactory.getClient();
-    }
-
-    @Override
-    public int getDesiredLeadImageDp() {
-        return DEFAULT_THUMB_SIZE;
-    }
-
-    @Override
-    public boolean isLoggedIn() {
-        return sessionManager.isUserLoggedIn();
-    }
-
-    @Override
-    public String getUserName() {
-        return sessionManager.getUserName();
-    }
-
-    @Override
-    public String getPassword() {
-        return sessionManager.getPassword();
-    }
-
-    @Override
-    public void updateAccount(@NonNull LoginResult result) {
-        sessionManager.updateAccount(result);
-    }
-
-    @Override
-    public SharedPreferenceCookieManager getCookies() {
-        if (!preferences.contains(COOKIE_STORE_NAME)) {
-            return null;
-        }
-        return GsonUnmarshaller.unmarshal(SharedPreferenceCookieManager.class,
-                preferences.getString(COOKIE_STORE_NAME, null));
-    }
-
-    @Override
-    public void setCookies(@NonNull SharedPreferenceCookieManager cookies) {
-        preferences.putString(COOKIE_STORE_NAME, GsonMarshaller.marshal(cookies));
-    }
-
-    @Override
-    public boolean logErrorsInsteadOfCrashing() {
-        return false;
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
deleted file mode 100644
index b50c746a7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
+++ /dev/null
@@ -1,314 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.annotation.SuppressLint;
-import android.app.Application;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.content.Context;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-import android.os.Build;
-import android.os.Process;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-
-import com.facebook.drawee.backends.pipeline.Fresco;
-import com.facebook.imagepipeline.core.ImagePipeline;
-import com.facebook.imagepipeline.core.ImagePipelineConfig;
-import com.mapbox.mapboxsdk.Mapbox;
-import com.squareup.leakcanary.LeakCanary;
-import com.squareup.leakcanary.RefWatcher;
-
-import io.reactivex.Completable;
-import org.acra.ACRA;
-import org.acra.annotation.AcraCore;
-import org.acra.annotation.AcraDialog;
-import org.acra.annotation.AcraMailSender;
-import org.acra.data.StringFormat;
-import org.wikipedia.AppAdapter;
-import org.wikipedia.language.AppLanguageLookUpTable;
-
-import java.io.File;
-import java.util.HashSet;
-import java.util.Set;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
-import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
-import fr.free.nrw.commons.category.CategoryDao;
-import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler;
-import fr.free.nrw.commons.concurrency.ThreadPoolService;
-import fr.free.nrw.commons.data.DBOpenHelper;
-import fr.free.nrw.commons.db.AppDatabase;
-import fr.free.nrw.commons.di.ApplicationlessInjection;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.logging.FileLoggingTree;
-import fr.free.nrw.commons.logging.LogUtils;
-import fr.free.nrw.commons.settings.Prefs;
-import fr.free.nrw.commons.upload.FileUtils;
-import fr.free.nrw.commons.utils.ConfigUtils;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.internal.functions.Functions;
-import io.reactivex.plugins.RxJavaPlugins;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static fr.free.nrw.commons.data.DBOpenHelper.CONTRIBUTIONS_TABLE;
-import static org.acra.ReportField.ANDROID_VERSION;
-import static org.acra.ReportField.APP_VERSION_CODE;
-import static org.acra.ReportField.APP_VERSION_NAME;
-import static org.acra.ReportField.PHONE_MODEL;
-import static org.acra.ReportField.STACK_TRACE;
-import static org.acra.ReportField.USER_COMMENT;
-
-@AcraCore(
-        buildConfigClass = BuildConfig.class,
-        resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
-        reportFormat = StringFormat.KEY_VALUE_LIST,
-        reportContent = {USER_COMMENT, APP_VERSION_CODE, APP_VERSION_NAME, ANDROID_VERSION, PHONE_MODEL, STACK_TRACE}
-)
-
-@AcraMailSender(
-        mailTo = "commons-app-android-private@googlegroups.com"
-)
-
-@AcraDialog(
-        resTheme = R.style.Theme_AppCompat_Dialog,
-        resText = R.string.crash_dialog_text,
-        resTitle = R.string.crash_dialog_title,
-        resCommentPrompt = R.string.crash_dialog_comment_prompt
-)
-
-public class CommonsApplication extends Application {
-    @Inject SessionManager sessionManager;
-    @Inject DBOpenHelper dbOpenHelper;
-
-    @Inject @Named("default_preferences") JsonKvStore defaultPrefs;
-
-    /**
-     * Constants begin
-     */
-    public static final int OPEN_APPLICATION_DETAIL_SETTINGS = 1001;
-
-    public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using [[COM:MOA|Commons Mobile App]]";
-
-    public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
-
-    public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App Feedback";
-
-    public static final String NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll";
-
-    public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --";
-
-    /**
-     * Constants End
-     */
-
-    private RefWatcher refWatcher;
-
-    private static CommonsApplication INSTANCE;
-    public static CommonsApplication getInstance() {
-        return INSTANCE;
-    }
-
-    private AppLanguageLookUpTable languageLookUpTable;
-    public AppLanguageLookUpTable getLanguageLookUpTable() {
-        return languageLookUpTable;
-    }
-
-    @Inject
-    AppDatabase appDatabase;
-
-    /**
-     * Used to declare and initialize various components and dependencies
-     */
-    @Override
-    public void onCreate() {
-        super.onCreate();
-
-        INSTANCE = this;
-        ACRA.init(this);
-        Mapbox.getInstance(this, getString(R.string.mapbox_commons_app_token));
-
-        ApplicationlessInjection
-                .getInstance(this)
-                .getCommonsApplicationComponent()
-                .inject(this);
-
-        AppAdapter.set(new CommonsAppAdapter(sessionManager, defaultPrefs));
-
-        initTimber();
-
-
-        if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
-            Set defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS);
-            if (null == defaultExifTagsSet) {
-                defaultExifTagsSet = new HashSet<>();
-            }
-            defaultExifTagsSet.add(getString(R.string.exif_tag_location));
-            defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet);
-        }
-
-//        Set DownsampleEnabled to True to downsample the image in case it's heavy
-        ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
-                .setDownsampleEnabled(true)
-                .build();
-        try {
-            Fresco.initialize(this, config);
-        } catch (Exception e) {
-            Timber.e(e);
-            // TODO: Remove when we're able to initialize Fresco in test builds.
-        }
-
-        createNotificationChannel(this);
-
-        languageLookUpTable = new AppLanguageLookUpTable(this);
-
-        // This handler will catch exceptions thrown from Observables after they are disposed,
-        // or from Observables that are (deliberately or not) missing an onError handler.
-        RxJavaPlugins.setErrorHandler(Functions.emptyConsumer());
-
-        if (setupLeakCanary() == RefWatcher.DISABLED) {
-            return;
-        }
-        // Fire progress callbacks for every 3% of uploaded content
-        System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
-    }
-
-    /**
-     * Plants debug and file logging tree.
-     * Timber lets you plant your own logging trees.
-     *
-     */
-    private void initTimber() {
-        boolean isBeta = ConfigUtils.isBetaFlavour();
-        String logFileName = isBeta ? "CommonsBetaAppLogs" : "CommonsAppLogs";
-        String logDirectory = LogUtils.getLogDirectory();
-        FileLoggingTree tree = new FileLoggingTree(
-                Log.VERBOSE,
-                logFileName,
-                logDirectory,
-                1000,
-                getFileLoggingThreadPool());
-
-        Timber.plant(tree);
-        Timber.plant(new Timber.DebugTree());
-    }
-
-    public static boolean isRoboUnitTest() {
-        return "robolectric".equals(Build.FINGERPRINT);
-    }
-
-    private ThreadPoolService getFileLoggingThreadPool() {
-        return new ThreadPoolService.Builder("file-logging-thread")
-                .setPriority(Process.THREAD_PRIORITY_LOWEST)
-                .setPoolSize(1)
-                .setExceptionHandler(new BackgroundPoolExceptionHandler())
-                .build();
-    }
-
-    public static void createNotificationChannel(@NonNull Context context) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-            NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
-            NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL);
-            if (channel == null) {
-                channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID_ALL,
-                        context.getString(R.string.notifications_channel_name_all), NotificationManager.IMPORTANCE_DEFAULT);
-                manager.createNotificationChannel(channel);
-            }
-        }
-    }
-
-    public String getUserAgent() {
-        return "Commons/" + ConfigUtils.getVersionNameWithSha(this) + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE;
-    }
-
-    /**
-     * Helps in setting up LeakCanary library
-     * @return instance of LeakCanary
-     */
-    protected RefWatcher setupLeakCanary() {
-        if (LeakCanary.isInAnalyzerProcess(this)) {
-            return RefWatcher.DISABLED;
-        }
-        return LeakCanary.install(this);
-    }
-
-  /**
-     * Provides a way to get member refWatcher
-     *
-     * @param context Application context
-     * @return application member refWatcher
-     */
-    public static RefWatcher getRefWatcher(Context context) {
-        CommonsApplication application = (CommonsApplication) context.getApplicationContext();
-        return application.refWatcher;
-    }
-
-    /**
-     * clears data of current application
-     * @param context Application context
-     * @param logoutListener Implementation of interface LogoutListener
-     */
-    @SuppressLint("CheckResult")
-    public void clearApplicationData(Context context, LogoutListener logoutListener) {
-        File cacheDirectory = context.getCacheDir();
-        File applicationDirectory = new File(cacheDirectory.getParent());
-        if (applicationDirectory.exists()) {
-            String[] fileNames = applicationDirectory.list();
-            for (String fileName : fileNames) {
-                if (!fileName.equals("lib")) {
-                    FileUtils.deleteFile(new File(applicationDirectory, fileName));
-                }
-            }
-        }
-
-        sessionManager.logout()
-            .andThen(Completable.fromAction(() ->{
-                Timber.d("All accounts have been removed");
-                clearImageCache();
-                //TODO: fix preference manager
-                defaultPrefs.clearAll();
-                defaultPrefs.putBoolean("firstrun", false);
-                updateAllDatabases();
-                }
-            ))
-            .subscribeOn(Schedulers.io())
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribe(logoutListener::onLogoutComplete, Timber::e);
-    }
-
-    /**
-     * Clear all images cache held by Fresco
-     */
-    private void clearImageCache() {
-        ImagePipeline imagePipeline = Fresco.getImagePipeline();
-        imagePipeline.clearCaches();
-    }
-
-    /**
-     * Deletes all tables and re-creates them.
-     */
-    private void updateAllDatabases() {
-        dbOpenHelper.getReadableDatabase().close();
-        SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
-
-        CategoryDao.Table.onDelete(db);
-        dbOpenHelper.deleteTable(db,CONTRIBUTIONS_TABLE);//Delete the contributions table in the existing db on older versions
-        appDatabase.getContributionDao().deleteAll();
-        BookmarkPicturesDao.Table.onDelete(db);
-        BookmarkLocationsDao.Table.onDelete(db);
-    }
-
-
-    /**
-     * Interface used to get log-out events
-     */
-    public interface LogoutListener {
-        void onLogoutComplete();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
new file mode 100644
index 000000000..89fdaa055
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
@@ -0,0 +1,417 @@
+package fr.free.nrw.commons
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.database.sqlite.SQLiteException
+import android.os.Build
+import android.os.Process
+import android.util.Log
+import androidx.multidex.MultiDexApplication
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.imagepipeline.core.ImagePipelineConfig
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable
+import fr.free.nrw.commons.category.CategoryDao
+import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
+import fr.free.nrw.commons.concurrency.ThreadPoolService
+import fr.free.nrw.commons.contributions.ContributionDao
+import fr.free.nrw.commons.data.DBOpenHelper
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.language.AppLanguageLookUpTable
+import fr.free.nrw.commons.logging.FileLoggingTree
+import fr.free.nrw.commons.logging.LogUtils
+import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher
+import fr.free.nrw.commons.settings.Prefs
+import fr.free.nrw.commons.upload.FileUtils
+import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
+import io.reactivex.Completable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.internal.functions.Functions
+import io.reactivex.plugins.RxJavaPlugins
+import io.reactivex.schedulers.Schedulers
+import org.acra.ACRA.init
+import org.acra.ReportField
+import org.acra.annotation.AcraCore
+import org.acra.annotation.AcraDialog
+import org.acra.annotation.AcraMailSender
+import org.acra.data.StringFormat
+import timber.log.Timber
+import timber.log.Timber.DebugTree
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Named
+
+@AcraCore(
+    buildConfigClass = BuildConfig::class,
+    resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
+    reportFormat = StringFormat.KEY_VALUE_LIST,
+    reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE]
+)
+
+@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false)
+
+@AcraDialog(
+    resTheme = R.style.Theme_AppCompat_Dialog,
+    resText = R.string.crash_dialog_text,
+    resTitle = R.string.crash_dialog_title,
+    resCommentPrompt = R.string.crash_dialog_comment_prompt
+)
+
+class CommonsApplication : MultiDexApplication() {
+
+    @Inject
+    lateinit var sessionManager: SessionManager
+
+    @Inject
+    lateinit var dbOpenHelper: DBOpenHelper
+
+    @Inject
+    @field:Named("default_preferences")
+    lateinit var defaultPrefs: JsonKvStore
+
+    @Inject
+    lateinit var cookieJar: CommonsCookieJar
+
+    @Inject
+    lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher
+
+    var languageLookUpTable: AppLanguageLookUpTable? = null
+        private set
+
+    @Inject
+    lateinit var contributionDao: ContributionDao
+
+    /**
+     * Used to declare and initialize various components and dependencies
+     */
+    override fun onCreate() {
+        super.onCreate()
+
+        instance = this
+        init(this)
+
+        ApplicationlessInjection
+            .getInstance(this)
+            .commonsApplicationComponent
+            .inject(this)
+
+        initTimber()
+
+        if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
+            var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS)
+            if (null == defaultExifTagsSet) {
+                defaultExifTagsSet = HashSet()
+            }
+            defaultExifTagsSet.add(getString(R.string.exif_tag_location))
+            defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet)
+        }
+
+        //        Set DownsampleEnabled to True to downsample the image in case it's heavy
+        val config = ImagePipelineConfig.newBuilder(this)
+            .setNetworkFetcher(customOkHttpNetworkFetcher)
+            .setDownsampleEnabled(true)
+            .build()
+        try {
+            Fresco.initialize(this, config)
+        } catch (e: Exception) {
+            Timber.e(e)
+            // TODO: Remove when we're able to initialize Fresco in test builds.
+        }
+
+        createNotificationChannel(this)
+
+        languageLookUpTable = AppLanguageLookUpTable(this)
+
+        // This handler will catch exceptions thrown from Observables after they are disposed,
+        // or from Observables that are (deliberately or not) missing an onError handler.
+        RxJavaPlugins.setErrorHandler(Functions.emptyConsumer())
+
+        // Fire progress callbacks for every 3% of uploaded content
+        System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
+    }
+
+    /**
+     * Plants debug and file logging tree. Timber lets you plant your own logging trees.
+     */
+    private fun initTimber() {
+        val isBeta = isBetaFlavour
+        val logFileName =
+            if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs"
+        val logDirectory = LogUtils.getLogDirectory()
+        //Delete stale logs if they have exceeded the specified size
+        deleteStaleLogs(logFileName, logDirectory)
+
+        val tree = FileLoggingTree(
+            Log.VERBOSE,
+            logFileName,
+            logDirectory,
+            1000,
+            fileLoggingThreadPool
+        )
+
+        Timber.plant(tree)
+        Timber.plant(DebugTree())
+    }
+
+    /**
+     * Deletes the logs zip file at the specified directory and file locations specified in the
+     * params
+     *
+     * @param logFileName
+     * @param logDirectory
+     */
+    private fun deleteStaleLogs(logFileName: String, logDirectory: String) {
+        try {
+            val file = File("$logDirectory/zip/$logFileName.zip")
+            if (file.exists() && file.totalSpace > 1000000) { // In Kbs
+                file.delete()
+            }
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+    private val fileLoggingThreadPool: ThreadPoolService
+        get() = ThreadPoolService.Builder("file-logging-thread")
+            .setPriority(Process.THREAD_PRIORITY_LOWEST)
+            .setPoolSize(1)
+            .setExceptionHandler(BackgroundPoolExceptionHandler())
+            .build()
+
+    val userAgent: String
+        get() = ("Commons/" + this.getVersionNameWithSha()
+                + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE)
+
+    /**
+     * clears data of current application
+     *
+     * @param context        Application context
+     * @param logoutListener Implementation of interface LogoutListener
+     */
+    @SuppressLint("CheckResult")
+    fun clearApplicationData(context: Context, logoutListener: LogoutListener) {
+        val cacheDirectory = context.cacheDir
+        val applicationDirectory = File(cacheDirectory.parent)
+        if (applicationDirectory.exists()) {
+            val fileNames = applicationDirectory.list()
+            for (fileName in fileNames) {
+                if (fileName != "lib") {
+                    FileUtils.deleteFile(File(applicationDirectory, fileName))
+                }
+            }
+        }
+
+        sessionManager.logout()
+            .andThen(Completable.fromAction { cookieJar.clear() })
+            .andThen(Completable.fromAction {
+                Timber.d("All accounts have been removed")
+                clearImageCache()
+                //TODO: fix preference manager
+                defaultPrefs.clearAll()
+                defaultPrefs.putBoolean("firstrun", false)
+                updateAllDatabases()
+            })
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) })
+    }
+
+    /**
+     * Clear all images cache held by Fresco
+     */
+    private fun clearImageCache() {
+        val imagePipeline = Fresco.getImagePipeline()
+        imagePipeline.clearCaches()
+    }
+
+    /**
+     * Deletes all tables and re-creates them.
+     */
+    private fun updateAllDatabases() {
+        dbOpenHelper.readableDatabase.close()
+        val db = dbOpenHelper.writableDatabase
+
+        CategoryDao.Table.onDelete(db)
+        dbOpenHelper.deleteTable(
+            db,
+            DBOpenHelper.CONTRIBUTIONS_TABLE
+        ) //Delete the contributions table in the existing db on older versions
+
+        dbOpenHelper.deleteTable(
+            db,
+            DBOpenHelper.BOOKMARKS_LOCATIONS
+        )
+
+        try {
+            contributionDao.deleteAll()
+        } catch (e: SQLiteException) {
+            Timber.e(e)
+        }
+        BookmarksTable.onDelete(db)
+        BookmarkItemsTable.onDelete(db)
+    }
+
+
+    /**
+     * Interface used to get log-out events
+     */
+    interface LogoutListener {
+        fun onLogoutComplete()
+    }
+
+    /**
+     * This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
+     * with relevant intent parameters. It does not perform the actual logout operation.
+     */
+    open class BaseLogoutListener : LogoutListener {
+        var ctx: Context
+        var loginMessage: String? = null
+        var userName: String? = null
+
+        /**
+         * Constructor for BaseLogoutListener.
+         *
+         * @param ctx Application context
+         */
+        constructor(ctx: Context) {
+            this.ctx = ctx
+        }
+
+        /**
+         * Constructor for BaseLogoutListener
+         *
+         * @param ctx           The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+         * @param loginMessage  Message to be displayed on the login page
+         * @param loginUsername Username to be pre-filled on the login page
+         */
+        constructor(
+            ctx: Context, loginMessage: String?,
+            loginUsername: String?
+        ) {
+            this.ctx = ctx
+            this.loginMessage = loginMessage
+            this.userName = loginUsername
+        }
+
+        override fun onLogoutComplete() {
+            Timber.d("Logout complete callback received.")
+            val loginIntent = Intent(ctx, LoginActivity::class.java)
+            loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+            if (loginMessage != null) {
+                loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage)
+            }
+            if (userName != null) {
+                loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName)
+            }
+
+            ctx.startActivity(loginIntent)
+        }
+    }
+
+    /**
+     * This class is an extension of BaseLogoutListener, providing additional functionality or customization
+     * for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
+     */
+    class ActivityLogoutListener : BaseLogoutListener {
+        var activity: Activity
+
+
+        /**
+         * Constructor for ActivityLogoutListener.
+         *
+         * @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
+         * @param ctx           The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+         */
+        constructor(activity: Activity, ctx: Context) : super(ctx) {
+            this.activity = activity
+        }
+
+        /**
+         * Constructor for ActivityLogoutListener with additional parameters for the login screen.
+         *
+         * @param activity      The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
+         * @param ctx           The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+         * @param loginMessage  Message to be displayed on the login page after logout.
+         * @param loginUsername Username to be pre-filled on the login page after logout.
+         */
+        constructor(
+            activity: Activity, ctx: Context?,
+            loginMessage: String?, loginUsername: String?
+        ) : super(activity, loginMessage, loginUsername) {
+            this.activity = activity
+        }
+
+        override fun onLogoutComplete() {
+            super.onLogoutComplete()
+            activity.finish()
+        }
+    }
+
+    companion object {
+
+        const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage"
+        const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername"
+
+        const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled"
+
+        /**
+         * Constants begin
+         */
+        const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001
+
+        const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]"
+
+        const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com"
+
+        const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback"
+
+        const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com"
+
+        const val REPORT_EMAIL_SUBJECT: String = "Report a violation"
+
+        const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll"
+
+        const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --"
+
+        /**
+         * Constants End
+         */
+
+        @JvmStatic
+        lateinit var instance: CommonsApplication
+            private set
+
+        @JvmField
+        var isPaused: Boolean = false
+
+        @JvmStatic
+        fun createNotificationChannel(context: Context) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                val manager = context
+                    .getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+                var channel = manager
+                    .getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL)
+                if (channel == null) {
+                    channel = NotificationChannel(
+                        NOTIFICATION_CHANNEL_ID_ALL,
+                        context.getString(R.string.notifications_channel_name_all),
+                        NotificationManager.IMPORTANCE_DEFAULT
+                    )
+                    manager.createNotificationChannel(channel)
+                }
+            }
+        }
+    }
+}
+
diff --git a/app/src/main/java/fr/free/nrw/commons/HandlerService.java b/app/src/main/java/fr/free/nrw/commons/HandlerService.java
deleted file mode 100644
index e5e1b3b1b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/HandlerService.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Intent;
-import android.os.Binder;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Message;
-
-import fr.free.nrw.commons.di.CommonsDaggerService;
-
-public abstract class HandlerService extends CommonsDaggerService {
-    private volatile Looper threadLooper;
-    private volatile ServiceHandler threadHandler;
-    private String serviceName;
-
-    private final class ServiceHandler extends Handler {
-        public ServiceHandler(Looper looper) {
-            super(looper);
-        }
-
-        @Override
-        public void handleMessage(Message msg) {
-            //FIXME: Google Photos bug
-            handle(msg.what, (T)msg.obj);
-            stopSelf(msg.arg1);
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        threadLooper.quit();
-        super.onDestroy();
-    }
-
-    public class HandlerServiceLocalBinder extends Binder {
-        public HandlerService getService() {
-            return HandlerService.this;
-        }
-    }
-
-    private final IBinder localBinder = new HandlerServiceLocalBinder();
-    @Override
-    public IBinder onBind(Intent intent) {
-        return localBinder;
-    }
-
-    protected HandlerService(String serviceName) {
-        this.serviceName = serviceName;
-    }
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        HandlerThread thread = new HandlerThread(serviceName);
-        thread.start();
-
-        threadLooper = thread.getLooper();
-        threadHandler = new ServiceHandler(threadLooper);
-    }
-
-    private void postMessage(int type, T t) {
-        Message msg = threadHandler.obtainMessage(type);
-        msg.obj = t;
-        threadHandler.sendMessage(msg);
-    }
-
-    public void queue(int what, T t) {
-        postMessage(what, t);
-    }
-
-    protected abstract void handle(int what, T t);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/License.java b/app/src/main/java/fr/free/nrw/commons/License.java
deleted file mode 100644
index 1fea236ee..000000000
--- a/app/src/main/java/fr/free/nrw/commons/License.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package fr.free.nrw.commons;
-
-import androidx.annotation.Nullable;
-
-/**
- * represents Licence object
- */
-public class License {
-    private String key;
-    private String template;
-    private String url;
-    private String name;
-
-    /**
-     * Constructs a new instance of License.
-     *
-     * @param key       license key
-     * @param template  license template
-     * @param url       license URL
-     * @param name      licence name
-     *
-     * @throws RuntimeException if License.key or Licence.template is null
-     */
-    public License(String key, String template, String url, String name) {
-        if (key == null) {
-            throw new RuntimeException("License.key must not be null");
-        }
-        if (template == null) {
-            throw new RuntimeException("License.template must not be null");
-        }
-        this.key = key;
-        this.template = template;
-        this.url = url;
-        this.name = name;
-    }
-
-    /**
-     * Gets the license key.
-     * @return license key as a String.
-     */
-    public String getKey() {
-        return key;
-    }
-
-    /**
-     * Gets the license template.
-     * @return license template as a String.
-     */
-    public String getTemplate() {
-        return template;
-    }
-
-    /**
-     * Gets the license name. If name is null, return license key.
-     * @return license name as string. if name null, license key as String
-     */
-    public String getName() {
-        if (name == null) {
-            // hack
-            return getKey();
-        } else {
-            return name;
-        }
-    }
-
-    /**
-     * Gets the license URL
-     *
-     * @param language license language
-     * @return URL
-     */
-    public @Nullable String getUrl(String language) {
-        if (url == null) {
-            return null;
-        } else {
-            return url.replace("$lang", language);
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/MapController.kt b/app/src/main/java/fr/free/nrw/commons/MapController.kt
new file mode 100644
index 000000000..5888b3f5f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MapController.kt
@@ -0,0 +1,46 @@
+package fr.free.nrw.commons
+
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Place
+
+abstract class MapController {
+    /**
+     * We pass this variable as a group of placeList and boundaryCoordinates
+     */
+    inner class NearbyPlacesInfo {
+        @JvmField
+        var placeList: List = emptyList() // List of nearby places
+
+        @JvmField
+        var boundaryCoordinates: Array = emptyArray() // Corners of nearby area
+
+        @JvmField
+        var currentLatLng: LatLng? = null // Current location when this places are populated
+
+        @JvmField
+        var searchLatLng: LatLng? = null // Search location for finding this places
+
+        @JvmField
+        var mediaList: List? = null // Search location for finding this places
+    }
+
+    /**
+     * We pass this variable as a group of placeList and boundaryCoordinates
+     */
+    inner class ExplorePlacesInfo {
+        @JvmField
+        var explorePlaceList: List = emptyList() // List of nearby places
+
+        @JvmField
+        var boundaryCoordinates: Array = emptyArray() // Corners of nearby area
+
+        @JvmField
+        var currentLatLng: LatLng? = null // Current location when this places are populated
+
+        @JvmField
+        var searchLatLng: LatLng? = null // Search location for finding this places
+
+        @JvmField
+        var mediaList: List = emptyList() // Search location for finding this places
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.java b/app/src/main/java/fr/free/nrw/commons/Media.java
deleted file mode 100644
index 548a5b01a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Media.java
+++ /dev/null
@@ -1,568 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.os.Parcel;
-import android.os.Parcelable;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.room.Entity;
-import androidx.room.PrimaryKey;
-
-import org.apache.commons.lang3.StringUtils;
-import org.wikipedia.dataclient.mwapi.MwQueryPage;
-import org.wikipedia.gallery.ExtMetadata;
-import org.wikipedia.gallery.ImageInfo;
-import org.wikipedia.page.PageTitle;
-
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-
-import fr.free.nrw.commons.location.LatLng;
-import fr.free.nrw.commons.utils.CommonsDateUtil;
-import fr.free.nrw.commons.utils.MediaDataExtractorUtil;
-
-@Entity
-public class Media implements Parcelable {
-
-    public static final Media EMPTY = new Media("");
-
-    // Primary metadata fields
-    @Nullable
-    public Uri localUri;
-    public String thumbUrl;
-    public String imageUrl;
-    public String filename;
-    public String description; // monolingual description on input...
-    public String discussion;
-    long dataLength;
-    public Date dateCreated;
-    @Nullable public  Date dateUploaded;
-    public int width;
-    public int height;
-    public String license;
-    public String licenseUrl;
-    public String creator;
-    public ArrayList categories; // as loaded at runtime?
-    public boolean requestedDeletion;
-    public HashMap descriptions; // multilingual descriptions as loaded
-    public HashMap tags = new HashMap<>();
-    @Nullable public  LatLng coordinates;
-
-    /**
-     * Provides local constructor
-     */
-    protected Media() {
-        this.categories = new ArrayList<>();
-        this.descriptions = new HashMap<>();
-    }
-
-    /**
-     * Provides a minimal constructor
-     *
-     * @param filename Media filename
-     */
-    public Media(String filename) {
-        this();
-        this.filename = filename;
-    }
-
-    /**
-     * Provide Media constructor
-     * @param localUri Media URI
-     * @param imageUrl Media image URL
-     * @param filename Media filename
-     * @param description Media description
-     * @param dataLength Media date length
-     * @param dateCreated Media creation date
-     * @param dateUploaded Media date uploaded
-     * @param creator Media creator
-     */
-    public Media(Uri localUri, String imageUrl, String filename, String description,
-                 long dataLength, Date dateCreated, Date dateUploaded, String creator) {
-        this();
-        this.localUri = localUri;
-        this.thumbUrl = imageUrl;
-        this.imageUrl = imageUrl;
-        this.filename = filename;
-        this.description = description;
-        this.dataLength = dataLength;
-        this.dateCreated = dateCreated;
-        this.dateUploaded = dateUploaded;
-        this.creator = creator;
-        this.categories = new ArrayList<>();
-        this.descriptions = new HashMap<>();
-    }
-
-    /**
-     * Creating Media object from MWQueryPage.
-     * Earlier only basic details were set for the media object but going forward,
-     * a full media object(with categories, descriptions, coordinates etc) can be constructed using this method
-     *
-     * @param page response from the API
-     * @return Media object
-     */
-    @Nullable
-    public static Media from(MwQueryPage page) {
-        ImageInfo imageInfo = page.imageInfo();
-        if (imageInfo == null) {
-            return null;
-        }
-        ExtMetadata metadata = imageInfo.getMetadata();
-        if (metadata == null) {
-            Media media = new Media(null, imageInfo.getOriginalUrl(),
-                    page.title(), "", 0, null, null, null);
-            if (!StringUtils.isBlank(imageInfo.getThumbUrl())) {
-                media.setThumbUrl(imageInfo.getThumbUrl());
-            }
-            return media;
-        }
-
-        Media media = new Media(null,
-                imageInfo.getOriginalUrl(),
-                page.title(),
-                "",
-                0,
-                safeParseDate(metadata.dateTime()),
-                safeParseDate(metadata.dateTime()),
-                getArtist(metadata)
-        );
-
-        if (!StringUtils.isBlank(imageInfo.getThumbUrl())) {
-            media.setThumbUrl(imageInfo.getThumbUrl());
-        }
-
-        String language = Locale.getDefault().getLanguage();
-        if (StringUtils.isBlank(language)) {
-            language = "default";
-        }
-
-        media.setDescriptions(Collections.singletonMap(language, metadata.imageDescription()));
-        media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.getCategories()));
-        String latitude = metadata.getGpsLatitude();
-        String longitude = metadata.getGpsLongitude();
-
-        if (!StringUtils.isBlank(latitude) && !StringUtils.isBlank(longitude)) {
-            LatLng latLng = new LatLng(Double.parseDouble(latitude), Double.parseDouble(longitude), 0);
-            media.setCoordinates(latLng);
-        }
-
-        media.setLicenseInformation(metadata.licenseShortName(), metadata.licenseUrl());
-        return media;
-    }
-
-    /**
-     * This method extracts the Commons Username from the artist HTML information
-     * @param metadata
-     * @return
-     */
-    private static String getArtist(ExtMetadata metadata) {
-        try {
-            String artistHtml = metadata.artist();
-            return artistHtml.substring(artistHtml.indexOf("title=\""), artistHtml.indexOf("\">"))
-                    .replace("title=\"User:", "");
-        } catch (Exception ex) {
-            return "";
-        }
-    }
-
-    public String getThumbUrl() {
-        return thumbUrl;
-    }
-
-    /**
-     * Gets tag of media
-     * @param key Media key
-     * @return Media tag
-     */
-    public Object getTag(String key) {
-        return tags.get(key);
-    }
-
-    /**
-     * Modifies( or creates a) tag of media
-     * @param key Media key
-     * @param value Media value
-     */
-    public void setTag(String key, String value) {
-        tags.put(key, value);
-    }
-
-    /**
-     * Gets media display title
-     * @return Media title
-     */
-    @NonNull public String getDisplayTitle() {
-        return filename != null ? getPageTitle().getDisplayTextWithoutNamespace().replaceFirst("[.][^.]+$", "") : "";
-    }
-
-    /**
-     * Gets file page title
-     * @return New media page title
-     */
-    @NonNull public PageTitle getPageTitle() {
-        return Utils.getPageTitle(getFilename());
-    }
-
-    /**
-     * Gets local URI
-     * @return Media local URI
-     */
-    public Uri getLocalUri() {
-        return localUri;
-    }
-
-    /**
-     * Gets image URL
-     * can be null.
-     * @return Image URL
-     */
-    @Nullable
-    public String getImageUrl() {
-        return imageUrl;
-    }
-
-    /**
-     * Gets the name of the file.
-     * @return file name as a string
-     */
-    public String getFilename() {
-        return filename;
-    }
-
-    /**
-     * Sets the name of the file.
-     * @param filename the new name of the file
-     */
-    public void setFilename(String filename) {
-        this.filename = filename;
-    }
-
-    /**
-     * Sets the discussion of the file.
-     * @param discussion
-     */
-    public void setDiscussion(String discussion) {
-        this.discussion = discussion;
-    }
-
-    /**
-     * Gets the file discussion as a string.
-     * @return file discussion as a string
-     */
-    public String getDiscussion() {
-        return discussion;
-    }
-
-    /**
-     * Gets the file description.
-     * @return file description as a string
-     */
-    public String getDescription() {
-        return description;
-    }
-
-    /**
-     * Sets the file description.
-     * @param description the new description of the file
-     */
-    public void setDescription(String description) {
-        this.description = description;
-    }
-
-    /**
-     * Gets the dataLength of the file.
-     * @return file dataLength as a long
-     */
-    public long getDataLength() {
-        return dataLength;
-    }
-
-    /**
-     * Sets the dataLength of the file.
-     * @param dataLength as a long
-     */
-    public void setDataLength(long dataLength) {
-        this.dataLength = dataLength;
-    }
-
-    /**
-     * Gets the creation date of the file.
-     * @return creation date as a Date
-     */
-    public Date getDateCreated() {
-        return dateCreated;
-    }
-
-    /**
-     * Sets the creation date of the file.
-     * @param date creation date as a Date
-     */
-    public void setDateCreated(Date date) {
-        this.dateCreated = date;
-    }
-
-    /**
-     * Gets the upload date of the file.
-     * Can be null.
-     * @return upload date as a Date
-     */
-    public @Nullable
-    Date getDateUploaded() {
-        return dateUploaded;
-    }
-
-    /**
-     * Gets the name of the creator of the file.
-     * @return creator name as a String
-     */
-    public String getCreator() {
-        return creator;
-    }
-
-    /**
-     * Sets the creator name of the file.
-     * @param creator creator name as a string
-     */
-    public void setCreator(String creator) {
-        this.creator = creator;
-    }
-
-    /**
-     * Gets the width of the media.
-     * @return file width as an int
-     */
-    public int getWidth() {
-        return width;
-    }
-
-    /**
-     * Sets the width of the media.
-     * @param width file width as an int
-     */
-    public void setWidth(int width) {
-        this.width = width;
-    }
-
-    /**
-     * Gets the height of the media.
-     * @return file height as an int
-     */
-    public int getHeight() {
-        return height;
-    }
-
-    /**
-     * Sets the height of the media.
-     * @param height file height as an int
-     */
-    public void setHeight(int height) {
-        this.height = height;
-    }
-
-    /**
-     * Gets the license name of the file.
-     * @return license as a String
-     */
-    public String getLicense() {
-        return license;
-    }
-
-    public void setThumbUrl(String thumbUrl) {
-        this.thumbUrl = thumbUrl;
-    }
-
-    public String getLicenseUrl() {
-        return licenseUrl;
-    }
-
-    /**
-     * Sets the license name of the file.
-     * @param license license name as a String
-     */
-    public void setLicenseInformation(String license, String licenseUrl) {
-        this.license = license;
-
-        if (!licenseUrl.startsWith("http://") && !licenseUrl.startsWith("https://")) {
-            licenseUrl = "https://" + licenseUrl;
-        }
-        this.licenseUrl = licenseUrl;
-    }
-
-    /**
-     * Gets the coordinates of where the file was created.
-     * @return file coordinates as a LatLng
-     */
-    public @Nullable
-    LatLng getCoordinates() {
-        return coordinates;
-    }
-
-    /**
-     * Sets the coordinates of where the file was created.
-     * @param coordinates file coordinates as a LatLng
-     */
-    public void setCoordinates(@Nullable LatLng coordinates) {
-        this.coordinates = coordinates;
-    }
-
-    /**
-     * Gets the categories the file falls under.
-     * @return file categories as an ArrayList of Strings
-     */
-    @SuppressWarnings("unchecked")
-    public ArrayList getCategories() {
-        return (ArrayList) categories.clone(); // feels dirty
-    }
-
-    /**
-     * Sets the categories the file falls under.
-     * 
-     * Does not append: i.e. will clear the current categories
-     * and then add the specified ones.
-     * @param categories file categories as a list of Strings
-     */
-    public void setCategories(List categories) {
-        this.categories.clear();
-        this.categories.addAll(categories);
-    }
-
-    /**
-     * Modifies (or sets) media descriptions
-     * @param descriptions Media descriptions
-     */
-    void setDescriptions(Map descriptions) {
-        this.descriptions.clear();
-        this.descriptions.putAll(descriptions);
-    }
-
-    /**
-     * Gets media description in preferred language
-     * @param preferredLanguage Language preferred
-     * @return Description in preferred language
-     */
-    public String getDescription(String preferredLanguage) {
-        if (descriptions.containsKey(preferredLanguage)) {
-            // See if the requested language is there.
-            return descriptions.get(preferredLanguage);
-        } else if (descriptions.containsKey("en")) {
-            // Ah, English. Language of the world, until the Chinese crush us.
-            return descriptions.get("en");
-        } else if (descriptions.containsKey("default")) {
-            // No languages marked...
-            return descriptions.get("default");
-        } else {
-            // FIXME: return the first available non-English description?
-            return "";
-        }
-    }
-
-    @Nullable private static Date safeParseDate(String dateStr) {
-        try {
-            return CommonsDateUtil.getIso8601DateFormatShort().parse(dateStr);
-        } catch (ParseException e) {
-            return null;
-        }
-    }
-
-    /**
-     * Set requested deletion to true
-     */
-    public void setRequestedDeletion(){
-        requestedDeletion = true;
-    }
-
-    /**
-     * Get the value of requested deletion
-     * @return boolean requestedDeletion
-     */
-    public boolean getRequestedDeletion(){
-        return requestedDeletion;
-    }
-
-    /**
-     * Sets the license name of the file.
-     *
-     * @param license license name as a String
-     */
-    public void setLicense(String license) {
-        this.license = license;
-    }
-
-
-    @Override
-    public int describeContents() {
-        return 0;
-    }
-
-    /**
-     * Creates a way to transfer information between two or more
-     * activities.
-     * @param dest Instance of Parcel
-     * @param flags Parcel flag
-     */
-    @Override
-    public void writeToParcel(Parcel dest, int flags) {
-        dest.writeParcelable(this.localUri, flags);
-        dest.writeString(this.thumbUrl);
-        dest.writeString(this.imageUrl);
-        dest.writeString(this.filename);
-        dest.writeString(this.description);
-        dest.writeString(this.discussion);
-        dest.writeLong(this.dataLength);
-        dest.writeLong(this.dateCreated != null ? this.dateCreated.getTime() : -1);
-        dest.writeLong(this.dateUploaded != null ? this.dateUploaded.getTime() : -1);
-        dest.writeInt(this.width);
-        dest.writeInt(this.height);
-        dest.writeString(this.license);
-        dest.writeString(this.licenseUrl);
-        dest.writeString(this.creator);
-        dest.writeStringList(this.categories);
-        dest.writeByte(this.requestedDeletion ? (byte) 1 : (byte) 0);
-        dest.writeSerializable(this.descriptions);
-        dest.writeSerializable(this.tags);
-        dest.writeParcelable(this.coordinates, flags);
-    }
-
-    protected Media(Parcel in) {
-        this.localUri = in.readParcelable(Uri.class.getClassLoader());
-        this.thumbUrl = in.readString();
-        this.imageUrl = in.readString();
-        this.filename = in.readString();
-        this.description = in.readString();
-        this.discussion = in.readString();
-        this.dataLength = in.readLong();
-        long tmpDateCreated = in.readLong();
-        this.dateCreated = tmpDateCreated == -1 ? null : new Date(tmpDateCreated);
-        long tmpDateUploaded = in.readLong();
-        this.dateUploaded = tmpDateUploaded == -1 ? null : new Date(tmpDateUploaded);
-        this.width = in.readInt();
-        this.height = in.readInt();
-        this.license = in.readString();
-        this.licenseUrl = in.readString();
-        this.creator = in.readString();
-        this.categories = in.createStringArrayList();
-        this.requestedDeletion = in.readByte() != 0;
-        this.descriptions = (HashMap) in.readSerializable();
-        this.tags = (HashMap) in.readSerializable();
-        this.coordinates = in.readParcelable(LatLng.class.getClassLoader());
-    }
-
-    public static final Creator CREATOR = new Creator() {
-        @Override
-        public Media createFromParcel(Parcel source) {
-            return new Media(source);
-        }
-
-        @Override
-        public Media[] newArray(int size) {
-            return new Media[size];
-        }
-    };
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt
new file mode 100644
index 000000000..dbe722e91
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/Media.kt
@@ -0,0 +1,206 @@
+package fr.free.nrw.commons
+
+import android.os.Parcelable
+import fr.free.nrw.commons.BuildConfig.COMMONS_URL
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.wikidata.model.WikiSite
+import fr.free.nrw.commons.wikidata.model.page.PageTitle
+import kotlinx.parcelize.IgnoredOnParcel
+import kotlinx.parcelize.Parcelize
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+
+@Parcelize
+class Media constructor(
+    /**
+     * @return pageId for the current media object
+     * Wikibase Identifier associated with media files
+     */
+    var pageId: String = UUID.randomUUID().toString(),
+    var thumbUrl: String? = null,
+    /**
+     * Gets image URL
+     * @return Image URL
+     */
+    var imageUrl: String? = null,
+    /**
+     * Gets the name of the file.
+     * @return file name as a string
+     */
+    var filename: String? = null,
+    /**
+     * The fallback description of the file, used if no other description is provided.
+     */
+    var fallbackDescription: String? = null,
+    /**
+     * Gets the upload date of the file.
+     * Can be null.
+     * @return upload date as a Date
+     */
+    var dateUploaded: Date? = null,
+    /**
+     * The license name of the file.
+     */
+    var license: String? = null,
+    /**
+     * The URL corresponding to the license.
+     */
+    var licenseUrl: String? = null,
+    /**
+     * The name of the creator of the file.
+     */
+    var author: String? = null,
+    /**
+     * The username of the uploader.
+     */
+    var user: String? = null,
+    /**
+     * The full name of the file's creator, if different from username.
+     */
+    var creatorName: String? = null,
+    /**
+     * Gets the categories the file falls under.
+     * @return file categories as an ArrayList of Strings
+     */
+    var categories: List? = null,
+    /**
+     * Gets the coordinates of where the file was created.
+     * @return file coordinates as a LatLng
+     */
+    var coordinates: LatLng? = null,
+    var captions: Map = emptyMap(),
+    var descriptions: Map = emptyMap(),
+    var depictionIds: List = emptyList(),
+    var creatorIds: List = emptyList(),
+    /**
+     * This field was added to find non-hidden categories
+     * Stores the mapping of category title to hidden attribute
+     * Example: "Mountains" => false, "CC-BY-SA-2.0" => true
+     */
+    var categoriesHiddenStatus: Map = emptyMap(),
+) : Parcelable {
+    constructor(
+        captions: Map,
+        categories: List?,
+        filename: String?,
+        fallbackDescription: String?,
+        author: String?,
+        user: String?,
+    ) : this(
+        filename = filename,
+        fallbackDescription = fallbackDescription,
+        dateUploaded = Date(),
+        author = author,
+        user = user,
+        categories = categories,
+        captions = captions,
+    )
+
+    constructor(
+        captions: Map,
+        categories: List?,
+        filename: String?,
+        fallbackDescription: String?,
+        author: String?,
+        user: String?,
+        dateUploaded: Date? = Date(),
+        license: String? = null,
+        licenseUrl: String? = null,
+        imageUrl: String? = null,
+        thumbUrl: String? = null,
+        coordinates: LatLng? = null,
+        descriptions: Map = emptyMap(),
+        depictionIds: List = emptyList(),
+        categoriesHiddenStatus: Map = emptyMap()
+    ) : this(
+        pageId = UUID.randomUUID().toString(),
+        filename = filename,
+        fallbackDescription = fallbackDescription,
+        dateUploaded = dateUploaded,
+        author = author,
+        user = user,
+        categories = categories,
+        captions = captions,
+        license = license,
+        licenseUrl = licenseUrl,
+        imageUrl = imageUrl,
+        thumbUrl = thumbUrl,
+        coordinates = coordinates,
+        descriptions = descriptions,
+        depictionIds = depictionIds,
+        categoriesHiddenStatus = categoriesHiddenStatus
+    )
+
+    /**
+     * Returns Author if it's not null or empty, otherwise
+     * returns user
+     * @return Author or User
+     */
+    @Deprecated("Use user for uploader username. Use attributedAuthor() for attribution. Note that the uploader may not be the creator/author.")
+    fun getAuthorOrUser(): String? {
+        return if (!author.isNullOrEmpty()) {
+            author
+        } else{
+            user
+        }
+    }
+
+    /**
+     * Returns author if it's not null or empty, otherwise
+     * returns creator name
+     * @return name of author or creator
+     */
+    fun getAttributedAuthor(): String? {
+        return if (!author.isNullOrEmpty()) {
+            author
+        } else{
+            creatorName
+        }
+    }
+
+    /**
+     * Gets media display title
+     * @return Media title
+     */
+    val displayTitle: String
+        get() =
+            if (filename != null) {
+                pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
+            } else {
+                ""
+            }
+
+    /**
+     * Gets file page title
+     * @return New media page title
+     */
+    val pageTitle: PageTitle
+        get() = PageTitle(filename!!, WikiSite(COMMONS_URL))
+
+    /**
+     * Returns wikicode to use the media file on a MediaWiki site
+     * @return
+     */
+    val wikiCode: String
+        get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
+
+    val mostRelevantCaption: String
+        get() =
+            captions[Locale.getDefault().language]
+                ?: captions.values.firstOrNull()
+                ?: displayTitle
+
+    /**
+     * Gets the categories the file falls under.
+     * @return file categories as an ArrayList of Strings
+     */
+    @IgnoredOnParcel
+    var addedCategories: List? = null
+        // TODO added categories should be removed. It is added for a short fix. On category update,
+        //  categories should be re-fetched instead
+        get() = field // getter
+        set(value) {
+            field = value
+        } // setter
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
deleted file mode 100644
index 0564626da..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package fr.free.nrw.commons;
-
-import androidx.core.text.HtmlCompat;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.media.MediaClient;
-import io.reactivex.Single;
-import timber.log.Timber;
-
-/**
- * Fetch additional media data from the network that we don't store locally.
- *
- * This includes things like category lists and multilingual descriptions,
- * which are not intrinsic to the media and may change due to editing.
- */
-@Singleton
-public class MediaDataExtractor {
-    private final MediaClient mediaClient;
-
-    @Inject
-    public MediaDataExtractor(MediaClient mediaClient) {
-        this.mediaClient = mediaClient;
-    }
-
-    /**
-     * Simplified method to extract all details required to show media details.
-     * It fetches media object, deletion status and talk page for the filename
-     * @param filename for which the details are to be fetched
-     * @return full Media object with all details including deletion status and talk page
-     */
-    public Single fetchMediaDetails(String filename) {
-        Single mediaSingle = getMediaFromFileName(filename);
-        Single pageExistsSingle = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + filename);
-        Single discussionSingle = getDiscussion(filename);
-        return Single.zip(mediaSingle, pageExistsSingle, discussionSingle, (media, deletionStatus, discussion) -> {
-            media.setDiscussion(discussion);
-            if (deletionStatus) {
-                media.setRequestedDeletion();
-            }
-            return media;
-        });
-    }
-
-    /**
-     * Method can be used to fetch media for a given filename
-     * @param filename Eg. File:Test.jpg
-     * @return return data rich Media object
-     */
-    public Single getMediaFromFileName(String filename) {
-        return mediaClient.getMedia(filename);
-    }
-
-    /**
-     * Fetch talk page from the MediaWiki API
-     * @param filename
-     * @return
-     */
-    private Single getDiscussion(String filename) {
-        return mediaClient.getPageHtml(filename.replace("File", "File talk"))
-                .map(discussion -> HtmlCompat.fromHtml(discussion, HtmlCompat.FROM_HTML_MODE_LEGACY).toString())
-                .onErrorReturn(throwable -> {
-                    Timber.e(throwable, "Error occurred while fetching discussion");
-                    return "";
-                });
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
new file mode 100644
index 000000000..970413283
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
@@ -0,0 +1,72 @@
+package fr.free.nrw.commons
+
+import androidx.core.text.HtmlCompat
+import fr.free.nrw.commons.media.IdAndLabels
+import fr.free.nrw.commons.media.MediaClient
+import fr.free.nrw.commons.media.PAGE_ID_PREFIX
+import io.reactivex.Single
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Fetch additional media data from the network that we don't store locally.
+ *
+ *
+ * This includes things like category lists and multilingual descriptions, which are not intrinsic
+ * to the media and may change due to editing.
+ */
+@Singleton
+class MediaDataExtractor
+    @Inject
+    constructor(
+        private val mediaClient: MediaClient,
+    ) {
+        fun fetchDepictionIdsAndLabels(media: Media) =
+                mediaClient
+                .getEntities(media.depictionIds)
+                .map {
+                    it
+                        .entities()
+                        .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
+                }.map { it.map { (key, value) -> IdAndLabels(key, value) } }
+                .onErrorReturn { emptyList() }
+
+        fun fetchCreatorIdsAndLabels(media: Media) =
+            mediaClient
+                .getEntities(media.creatorIds)
+                .map {
+                    it
+                        .entities()
+                        .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
+                }.map { it.map { (key, value) -> IdAndLabels(key, value) } }
+                .onErrorReturn { emptyList() }
+
+        fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
+
+        fun fetchDiscussion(media: Media) =
+            mediaClient
+                .getPageHtml(media.filename!!.replace("File", "File talk"))
+                .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
+                .onErrorReturn {
+                    Timber.d("Error occurred while fetching discussion")
+                    ""
+                }
+
+        fun refresh(media: Media): Single =
+            Single.ambArray(
+                mediaClient
+                    .getMediaById(PAGE_ID_PREFIX + media.pageId)
+                    .onErrorResumeNext { Single.never() },
+                mediaClient
+                    .getMediaSuppressingErrors(media.filename)
+                    .onErrorResumeNext { Single.never() },
+            )
+
+        fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
+
+        /**
+         * Fetches wikitext from mediaClient
+         */
+        fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title)
+    }
diff --git a/app/src/main/java/fr/free/nrw/commons/MvpView.java b/app/src/main/java/fr/free/nrw/commons/MvpView.java
deleted file mode 100644
index 7485b2aaf..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MvpView.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package fr.free.nrw.commons;
-
-/**
- * Base interface for all the views
- */
-public interface MvpView {
-    void showMessage(String message);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java
deleted file mode 100644
index 6fe317c0a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package fr.free.nrw.commons;
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.dataclient.SharedPreferenceCookieManager;
-import org.wikipedia.dataclient.okhttp.HttpStatusException;
-
-import java.io.File;
-import java.io.IOException;
-
-import okhttp3.Cache;
-import okhttp3.Interceptor;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.Response;
-import okhttp3.logging.HttpLoggingInterceptor;
-
-public final class OkHttpConnectionFactory {
-    private static final String CACHE_DIR_NAME = "okhttp-cache";
-    private static final long NET_CACHE_SIZE = 64 * 1024 * 1024;
-    @NonNull private static final Cache NET_CACHE = new Cache(new File(CommonsApplication.getInstance().getCacheDir(),
-            CACHE_DIR_NAME), NET_CACHE_SIZE);
-
-    @NonNull private static OkHttpClient CLIENT = createClient();
-
-    @NonNull public static OkHttpClient getClient() {
-        return CLIENT;
-    }
-
-    @NonNull
-    private static OkHttpClient createClient() {
-        return new OkHttpClient.Builder()
-                .cookieJar(SharedPreferenceCookieManager.getInstance())
-                .cache(NET_CACHE)
-                .addInterceptor(getLoggingInterceptor())
-                .addInterceptor(new UnsuccessfulResponseInterceptor())
-                .addInterceptor(new CommonHeaderRequestInterceptor())
-                .build();
-    }
-
-    private static HttpLoggingInterceptor getLoggingInterceptor() {
-        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor()
-                .setLevel(HttpLoggingInterceptor.Level.BASIC);
-
-        httpLoggingInterceptor.redactHeader("Authorization");
-        httpLoggingInterceptor.redactHeader("Cookie");
-
-        return httpLoggingInterceptor;
-    }
-
-    private static class CommonHeaderRequestInterceptor implements Interceptor {
-        @Override @NonNull public Response intercept(@NonNull Chain chain) throws IOException {
-            Request request = chain.request().newBuilder()
-                    .header("User-Agent", CommonsApplication.getInstance().getUserAgent())
-                    .build();
-            return chain.proceed(request);
-        }
-    }
-
-    public static class UnsuccessfulResponseInterceptor implements Interceptor {
-        @Override @NonNull public Response intercept(@NonNull Chain chain) throws IOException {
-            Response rsp = chain.proceed(chain.request());
-            if (rsp.isSuccessful()) {
-                return rsp;
-            }
-            throw new HttpStatusException(rsp);
-        }
-    }
-
-    private OkHttpConnectionFactory() {
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt
new file mode 100644
index 000000000..c54c3aefb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt
@@ -0,0 +1,135 @@
+package fr.free.nrw.commons
+
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.wikidata.GsonUtil
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
+import fr.free.nrw.commons.wikidata.mwapi.MwErrorResponse
+import fr.free.nrw.commons.wikidata.mwapi.MwIOException
+import fr.free.nrw.commons.wikidata.mwapi.MwLegacyServiceError
+import okhttp3.Cache
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.logging.HttpLoggingInterceptor
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+object OkHttpConnectionFactory {
+    private const val CACHE_DIR_NAME = "okhttp-cache"
+    private const val NET_CACHE_SIZE = (64 * 1024 * 1024).toLong()
+
+    @VisibleForTesting
+    var CLIENT: OkHttpClient? = null
+
+    fun getClient(cookieJar: CommonsCookieJar): OkHttpClient {
+        if (CLIENT == null) {
+            CLIENT = createClient(cookieJar)
+        }
+        return CLIENT!!
+    }
+
+    private fun createClient(cookieJar: CommonsCookieJar): OkHttpClient {
+        return OkHttpClient.Builder()
+            .cookieJar(cookieJar)
+            .cache(
+                if (CommonsApplication.instance != null) Cache(
+                    File(CommonsApplication.instance.cacheDir, CACHE_DIR_NAME),
+                    NET_CACHE_SIZE
+                ) else null
+            )
+            .connectTimeout(120, TimeUnit.SECONDS)
+            .writeTimeout(120, TimeUnit.SECONDS)
+            .readTimeout(120, TimeUnit.SECONDS)
+            .addInterceptor(HttpLoggingInterceptor().apply {
+                setLevel(HttpLoggingInterceptor.Level.BASIC)
+                redactHeader("Authorization")
+                redactHeader("Cookie")
+            })
+            .addInterceptor(UnsuccessfulResponseInterceptor())
+            .addInterceptor(CommonHeaderRequestInterceptor())
+            .build()
+    }
+}
+
+class CommonHeaderRequestInterceptor : Interceptor {
+    @Throws(IOException::class)
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val request = chain.request().newBuilder()
+            .header("User-Agent", CommonsApplication.instance.userAgent)
+            .build()
+        return chain.proceed(request)
+    }
+}
+
+private const val SUPPRESS_ERROR_LOG = "x-commons-suppress-error-log"
+const val SUPPRESS_ERROR_LOG_HEADER: String = "$SUPPRESS_ERROR_LOG: true"
+
+private class UnsuccessfulResponseInterceptor : Interceptor {
+    @Throws(IOException::class)
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val rq = chain.request()
+
+        // If the request contains our special "suppress errors" header, make note of it
+        // but don't pass that on to the server.
+        val suppressErrors = rq.headers.names().contains(SUPPRESS_ERROR_LOG)
+        val request = rq.newBuilder()
+            .removeHeader(SUPPRESS_ERROR_LOG)
+            .build()
+
+        val rsp = chain.proceed(request)
+
+        // Do not intercept certain requests and let the caller handle the errors
+        if (isExcludedUrl(chain.request())) {
+            return rsp
+        }
+        if (rsp.isSuccessful) {
+            try {
+                rsp.peekBody(ERRORS_PREFIX.length.toLong()).use { responseBody ->
+                    if (ERRORS_PREFIX == responseBody.string()) {
+                        rsp.body.use { body ->
+                            val bodyString = body!!.string()
+
+                            throw MwIOException(
+                                "MediaWiki API returned error: $bodyString",
+                                GsonUtil.defaultGson.fromJson(
+                                    bodyString,
+                                    MwErrorResponse::class.java
+                                ).error!!,
+                            )
+                        }
+                    }
+                }
+            } catch (e: MwIOException) {
+                // Log the error as debug (and therefore, "expected") or at error level
+                if (suppressErrors) {
+                    Timber.d(e, "Suppressed (known / expected) error")
+                } else {
+                    Timber.e(e)
+                    throw e
+                }
+            }
+            return rsp
+        }
+        throw IOException("Unsuccessful response")
+    }
+
+    private fun isExcludedUrl(request: Request): Boolean {
+        val requestUrl = request.url.toString()
+        for (url in DO_NOT_INTERCEPT) {
+            if (requestUrl.contains(url)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    companion object {
+        val DO_NOT_INTERCEPT = listOf(
+            "api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1"
+        )
+        const val ERRORS_PREFIX = "{\"error"
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/Urls.kt b/app/src/main/java/fr/free/nrw/commons/Urls.kt
index 88470bc69..3eb7ee243 100644
--- a/app/src/main/java/fr/free/nrw/commons/Urls.kt
+++ b/app/src/main/java/fr/free/nrw/commons/Urls.kt
@@ -3,12 +3,16 @@ package fr.free.nrw.commons
 internal object Urls {
     const val NEW_ISSUE_URL = "https://github.com/commons-app/apps-android-commons/issues"
     const val GITHUB_REPO_URL = "https://github.com/commons-app/apps-android-commons"
+    const val GITHUB_PACKAGE_NAME = "com.github.android"
     const val WEBSITE_URL = "https://commons-app.github.io"
     const val CREDITS_URL = "https://github.com/commons-app/apps-android-commons/blob/master/CREDITS"
+    const val USER_GUIDE_URL = "https://commons-app.github.io/docs.html"
     const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md"
     const val PLAY_STORE_PREFIX = "market://details?id="
     const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id="
-    const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language="
+    const val TRANSLATE_WIKI_URL =
+        "https://translatewiki.net/w/i.php?title=Special:Translate" +
+            "&group=commons-android-strings&filter=%21translated&action=translate&language="
     const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985"
     const val FACEBOOK_APP_URL = "fb://page/1921335171459985"
     const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"
diff --git a/app/src/main/java/fr/free/nrw/commons/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java
deleted file mode 100644
index 5f75c86ba..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Utils.java
+++ /dev/null
@@ -1,204 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.ClipData;
-import android.content.ClipboardManager;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.text.SpannableString;
-import android.text.style.UnderlineSpan;
-import android.view.View;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.annotation.NonNull;
-import androidx.browser.customtabs.CustomTabsIntent;
-import androidx.core.content.ContextCompat;
-
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.page.PageTitle;
-
-import java.util.Locale;
-import java.util.regex.Pattern;
-
-import fr.free.nrw.commons.location.LatLng;
-import fr.free.nrw.commons.settings.Prefs;
-import fr.free.nrw.commons.utils.ViewUtil;
-import timber.log.Timber;
-
-import static android.widget.Toast.LENGTH_SHORT;
-
-public class Utils {
-
-    public static PageTitle getPageTitle(@NonNull String title) {
-        return new PageTitle(title, new WikiSite(BuildConfig.COMMONS_URL));
-    }
-
-    /**
-     * Generates licence name with given ID
-     * @param license License ID
-     * @return Name of license
-     */
-    public static int licenseNameFor(String license) {
-        switch (license) {
-            case Prefs.Licenses.CC_BY_3:
-                return R.string.license_name_cc_by;
-            case Prefs.Licenses.CC_BY_4:
-                return R.string.license_name_cc_by_four;
-            case Prefs.Licenses.CC_BY_SA_3:
-                return R.string.license_name_cc_by_sa;
-            case Prefs.Licenses.CC_BY_SA_4:
-                return R.string.license_name_cc_by_sa_four;
-            case Prefs.Licenses.CC0:
-                return R.string.license_name_cc0;
-        }
-        throw new IllegalStateException("Unrecognized license value: " + license);
-    }
-
-    /**
-     * Generates license url with given ID
-     * @param license License ID
-     * @return Url of license
-     */
-
-
-    @NonNull
-    public static String licenseUrlFor(String license) {
-        switch (license) {
-            case Prefs.Licenses.CC_BY_3:
-                return "https://creativecommons.org/licenses/by/3.0/";
-            case Prefs.Licenses.CC_BY_4:
-                return "https://creativecommons.org/licenses/by/4.0/";
-            case Prefs.Licenses.CC_BY_SA_3:
-                return "https://creativecommons.org/licenses/by-sa/3.0/";
-            case Prefs.Licenses.CC_BY_SA_4:
-                return "https://creativecommons.org/licenses/by-sa/4.0/";
-            case Prefs.Licenses.CC0:
-                return "https://creativecommons.org/publicdomain/zero/1.0/";
-            default:
-                throw new IllegalStateException("Unrecognized license value: " + license);
-        }
-    }
-
-    /**
-     * Adds extension to filename. Converts to .jpg if system provides .jpeg, adds .jpg if no extension detected
-     * @param title File name
-     * @param extension Correct extension
-     * @return File with correct extension
-     */
-    public static String fixExtension(String title, String extension) {
-        Pattern jpegPattern = Pattern.compile("\\.jpeg$", Pattern.CASE_INSENSITIVE);
-
-        // People are used to ".jpg" more than ".jpeg" which the system gives us.
-        if (extension != null && extension.toLowerCase(Locale.ENGLISH).equals("jpeg")) {
-            extension = "jpg";
-        }
-        title = jpegPattern.matcher(title).replaceFirst(".jpg");
-        if (extension != null && !title.toLowerCase(Locale.getDefault())
-                .endsWith("." + extension.toLowerCase(Locale.ENGLISH))) {
-            title += "." + extension;
-        }
-
-        // If extension is still null, make it jpg. (Hotfix for https://github.com/commons-app/apps-android-commons/issues/228)
-        // If title has an extension in it, if won't be true
-        if (extension == null && title.lastIndexOf(".")<=0) {
-           extension = "jpg";
-           title += "." + extension;
-        }
-
-        return title;
-    }
-
-    /**
-     * Launches intent to rate app
-     * @param context
-     */
-    public static void rateApp(Context context) {
-        final String appPackageName = context.getPackageName();
-        try {
-            context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.PLAY_STORE_PREFIX + appPackageName)));
-        }
-        catch (android.content.ActivityNotFoundException anfe) {
-            handleWebUrl(context, Uri.parse(Urls.PLAY_STORE_URL_PREFIX + appPackageName));
-        }
-    }
-
-    /**
-     * Opens Custom Tab Activity with in-app browser for the specified URL.
-     * Launches intent for web URL
-     * @param context
-     * @param url
-     */
-    public static void handleWebUrl(Context context, Uri url) {
-        Timber.d("Launching web url %s", url.toString());
-        Intent browserIntent = new Intent(Intent.ACTION_VIEW, url);
-        if (browserIntent.resolveActivity(context.getPackageManager()) == null) {
-            Toast toast = Toast.makeText(context, context.getString(R.string.no_web_browser), LENGTH_SHORT);
-            toast.show();
-            return;
-        }
-
-        CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
-        builder.setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor));
-        builder.setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.primaryDarkColor));
-        builder.setExitAnimations(context, android.R.anim.slide_in_left, android.R.anim.slide_out_right);
-        CustomTabsIntent customTabsIntent = builder.build();
-        // Clear previous browser tasks, so that back/exit buttons work as intended.
-        customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
-        customTabsIntent.launchUrl(context, url);
-    }
-
-    /**
-     * Util function to handle geo coordinates
-     * It no longer depends on google maps and any app capable of handling the map intent can handle it
-     * @param context
-     * @param latLng
-     */
-    public static void handleGeoCoordinates(Context context, LatLng latLng) {
-        Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri());
-        if (mapIntent.resolveActivity(context.getPackageManager()) != null) {
-            context.startActivity(mapIntent);
-        } else {
-            ViewUtil.showShortToast(context, context.getString(R.string.map_application_missing));
-        }
-    }
-
-    /**
-     * To take screenshot of the screen and return it in Bitmap format
-     *
-     * @param view
-     * @return
-     */
-    public static Bitmap getScreenShot(View view) {
-        View screenView = view.getRootView();
-        screenView.setDrawingCacheEnabled(true);
-        Bitmap bitmap = Bitmap.createBitmap(screenView.getDrawingCache());
-        screenView.setDrawingCacheEnabled(false);
-        return bitmap;
-    }
-
-    /*
-    *Copies the content to the clipboard
-    *
-    */
-    public static void copy(String label,String text, Context context){
-        ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
-        ClipData clip = ClipData.newPlainText(label, text);
-        clipboard.setPrimaryClip(clip);
-    }
-
-    /**
-     * This method sets underlined string text to a TextView
-     *
-     * @param textView TextView associated with string resource
-     * @param stringResourceName string resource name
-     * @param context
-     */
-    public static void setUnderlinedText(TextView textView, int stringResourceName, Context context) {
-        SpannableString content = new SpannableString(context.getString(stringResourceName));
-        content.setSpan(new UnderlineSpan(), 0, content.length(), 0);
-        textView.setText(content);
-    }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/ViewHolder.java b/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
deleted file mode 100644
index 7181d85cc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-
-public interface ViewHolder {
-    void bindModel(Context context, T model);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt
new file mode 100644
index 000000000..a8ce8c79a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt
@@ -0,0 +1,44 @@
+package fr.free.nrw.commons
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import java.util.Locale
+
+/**
+ * This adapter will be used to display fragments in a ViewPager
+ */
+class ViewPagerAdapter : FragmentPagerAdapter {
+    private val context: Context
+    private var fragmentList: List = emptyList()
+    private var fragmentTitleList: List = emptyList()
+
+    constructor(context: Context, manager: FragmentManager) : super(manager) {
+        this.context = context
+    }
+
+    constructor(context: Context, manager: FragmentManager, behavior: Int) : super(manager, behavior) {
+        this.context = context
+    }
+
+    override fun getItem(position: Int): Fragment = fragmentList[position]
+
+    override fun getPageTitle(position: Int): CharSequence = fragmentTitleList[position]
+
+    override fun getCount(): Int = fragmentList.size
+
+    fun setTabs(vararg titlesToFragments: Pair) {
+        // Enforce that every title must come from strings.xml and all will consistently be uppercase
+        fragmentTitleList = titlesToFragments.map {
+            context.getString(it.first).uppercase(Locale.ROOT)
+        }
+        fragmentList = titlesToFragments.map { it.second }
+    }
+
+    companion object {
+        // Convenience method for Java callers, can be removed when everything is migrated
+        @JvmStatic
+        fun pairOf(first: Int, second: Fragment) = first to second
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
deleted file mode 100644
index fa9b452dc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
+++ /dev/null
@@ -1,98 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-
-import androidx.viewpager.widget.ViewPager;
-
-import com.viewpagerindicator.CirclePageIndicator;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.quiz.QuizActivity;
-import fr.free.nrw.commons.theme.BaseActivity;
-import fr.free.nrw.commons.utils.ConfigUtils;
-
-public class WelcomeActivity extends BaseActivity {
-
-    @BindView(R.id.welcomePager)
-    ViewPager pager;
-    @BindView(R.id.welcomePagerIndicator)
-    CirclePageIndicator indicator;
-
-    private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
-    private boolean isQuiz;
-
-    /**
-     * Initialises exiting fields and dependencies
-     *
-     * @param savedInstanceState WelcomeActivity bundled data
-     */
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_welcome);
-
-        if (getIntent() != null) {
-            Bundle bundle = getIntent().getExtras();
-            if (bundle != null) {
-                isQuiz = bundle.getBoolean("isQuiz");
-            }
-        } else {
-            isQuiz = false;
-        }
-
-        // Enable skip button if beta flavor
-        if (ConfigUtils.isBetaFlavour()) {
-            findViewById(R.id.finishTutorialButton).setVisibility(View.VISIBLE);
-        }
-
-        ButterKnife.bind(this);
-
-        pager.setAdapter(adapter);
-        indicator.setViewPager(pager);
-    }
-
-    /**
-     * References WelcomePageAdapter to null before the activity is destroyed
-     */
-    @Override
-    public void onDestroy() {
-        if (isQuiz) {
-            Intent i = new Intent(WelcomeActivity.this, QuizActivity.class);
-            startActivity(i);
-        }
-        super.onDestroy();
-    }
-
-    /**
-     * Creates a way to change current activity to WelcomeActivity
-     *
-     * @param context Activity context
-     */
-    public static void startYourself(Context context) {
-        Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
-        context.startActivity(welcomeIntent);
-    }
-
-    /**
-     * Override onBackPressed() to go to previous tutorial 'pages' if not on first page
-     */
-    @Override
-    public void onBackPressed() {
-        if (pager.getCurrentItem() != 0) {
-            pager.setCurrentItem(pager.getCurrentItem() - 1, true);
-        } else {
-            finish();
-        }
-    }
-
-    @OnClick(R.id.finishTutorialButton)
-    public void finishTutorial() {
-        defaultKvStore.putBoolean("firstrun", false);
-        finish();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt
new file mode 100644
index 000000000..0882ba117
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt
@@ -0,0 +1,80 @@
+package fr.free.nrw.commons
+
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import fr.free.nrw.commons.databinding.ActivityWelcomeBinding
+import fr.free.nrw.commons.databinding.PopupForCopyrightBinding
+import fr.free.nrw.commons.quiz.QuizActivity
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+
+class WelcomeActivity : BaseActivity() {
+    private var binding: ActivityWelcomeBinding? = null
+    private var isQuiz = false
+
+    /**
+     * Initialises exiting fields and dependencies
+     *
+     * @param savedInstanceState WelcomeActivity bundled data
+     */
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        binding = ActivityWelcomeBinding.inflate(layoutInflater)
+        applyEdgeToEdgeAllInsets(binding!!.welcomePager.rootView)
+        setContentView(binding!!.root)
+
+        isQuiz = intent?.extras?.getBoolean("isQuiz", false) ?: false
+
+        // Enable skip button if beta flavor
+        if (isBetaFlavour) {
+            binding!!.finishTutorialButton.visibility = View.VISIBLE
+
+            val copyrightBinding = PopupForCopyrightBinding.inflate(layoutInflater)
+
+            val dialog = AlertDialog.Builder(this)
+                .setView(copyrightBinding.root)
+                .setCancelable(false)
+                .create()
+            dialog.show()
+
+            copyrightBinding.buttonOk.setOnClickListener { v: View? -> dialog.dismiss() }
+        }
+
+        val adapter = WelcomePagerAdapter()
+        binding!!.welcomePager.adapter = adapter
+        binding!!.welcomePagerIndicator.setViewPager(binding!!.welcomePager)
+        binding!!.finishTutorialButton.setOnClickListener { v: View? -> finishTutorial() }
+    }
+
+    public override fun onDestroy() {
+        if (isQuiz) {
+            startActivity(Intent(this, QuizActivity::class.java))
+        }
+        super.onDestroy()
+    }
+
+    override fun onBackPressed() {
+        if (binding!!.welcomePager.currentItem != 0) {
+            binding!!.welcomePager.setCurrentItem(binding!!.welcomePager.currentItem - 1, true)
+        } else {
+            if (defaultKvStore.getBoolean("firstrun", true)) {
+                finishAffinity()
+            } else {
+                super.onBackPressed()
+            }
+        }
+    }
+
+    fun finishTutorial() {
+        defaultKvStore.putBoolean("firstrun", false)
+        finish()
+    }
+}
+
+fun Context.startWelcome() {
+    startActivity(Intent(this, WelcomeActivity::class.java))
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
deleted file mode 100644
index 7017c028b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.text.Html;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import androidx.viewpager.widget.PagerAdapter;
-
-public class WelcomePagerAdapter extends PagerAdapter {
-    private static final int[] PAGE_LAYOUTS = new int[]{
-            R.layout.welcome_wikipedia,
-            R.layout.welcome_do_upload,
-            R.layout.welcome_dont_upload,
-            R.layout.welcome_image_example,
-            R.layout.welcome_final
-    };
-
-    /**
-     * Gets total number of layouts
-     * @return Number of layouts
-     */
-    @Override
-    public int getCount() {
-        return PAGE_LAYOUTS.length;
-    }
-
-    /**
-     * Compares given view with provided object
-     * @param view Adapter view
-     * @param object Adapter object
-     * @return Equality between view and object
-     */
-    @Override
-    public boolean isViewFromObject(View view, Object object) {
-        return (view == object);
-    }
-
-    @Override
-    public Object instantiateItem(ViewGroup container, int position) {
-        LayoutInflater inflater = LayoutInflater.from(container.getContext());
-        ViewGroup layout = (ViewGroup) inflater.inflate(PAGE_LAYOUTS[position], container, false);
-
-        // If final page
-        if (position == PAGE_LAYOUTS.length - 1) {
-            // Add link to more information
-            TextView moreInfo = layout.findViewById(R.id.welcomeInfo);
-            Utils.setUnderlinedText(moreInfo, R.string.welcome_help_button_text, container.getContext());
-            moreInfo.setOnClickListener(view -> Utils.handleWebUrl(
-                    container.getContext(),
-                    Uri.parse("https://commons.wikimedia.org/wiki/Help:Contents")
-            ));
-
-            // Handle click of finishTutorialButton ("YES!" button) inside layout
-            layout.findViewById(R.id.finishTutorialButton)
-                    .setOnClickListener(view -> ((WelcomeActivity) container.getContext()).finishTutorial());
-        }
-
-        container.addView(layout);
-        return layout;
-    }
-
-    /**
-     * Provides a way to remove an item from container
-     * @param container Adapter view group container
-     * @param position Index of item
-     * @param obj Adapter object
-     */
-    @Override
-    public void destroyItem(ViewGroup container, int position, Object obj) {
-        container.removeView((View) obj);
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
new file mode 100644
index 000000000..0cb88c48b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
@@ -0,0 +1,70 @@
+package fr.free.nrw.commons
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.net.toUri
+import androidx.viewpager.widget.PagerAdapter
+import fr.free.nrw.commons.utils.UnderlineUtils.setUnderlinedText
+import fr.free.nrw.commons.utils.handleWebUrl
+
+class WelcomePagerAdapter : PagerAdapter() {
+    /**
+     * Gets total number of layouts
+     * @return Number of layouts
+     */
+    override fun getCount(): Int = PAGE_LAYOUTS.size
+
+    /**
+     * Compares given view with provided object
+     * @param view Adapter view
+     * @param obj Adapter object
+     * @return Equality between view and object
+     */
+    override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj)
+
+    /**
+     * Provides a way to remove an item from container
+     * @param container Adapter view group container
+     * @param position Index of item
+     * @param obj Adapter object
+     */
+    override fun destroyItem(container: ViewGroup, position: Int, obj: Any) =
+        container.removeView(obj as View)
+
+    override fun instantiateItem(container: ViewGroup, position: Int): Any {
+        val inflater = LayoutInflater.from(container.context)
+        val layout = inflater.inflate(PAGE_LAYOUTS[position], container, false) as ViewGroup
+
+        // If final page
+        if (position == PAGE_LAYOUTS.size - 1) {
+            // Add link to more information
+            val moreInfo = layout.findViewById(R.id.welcomeInfo)
+            setUnderlinedText(moreInfo, R.string.welcome_help_button_text)
+            moreInfo.setOnClickListener {
+                handleWebUrl(
+                    container.context,
+                    "https://commons.wikimedia.org/wiki/Help:Contents".toUri()
+                )
+            }
+
+            // Handle click of finishTutorialButton ("YES!" button) inside layout
+            layout.findViewById(R.id.finishTutorialButton)
+                .setOnClickListener { view: View? -> (container.context as WelcomeActivity).finishTutorial() }
+        }
+
+        container.addView(layout)
+        return layout
+    }
+
+    companion object {
+        private val PAGE_LAYOUTS = intArrayOf(
+            R.layout.welcome_wikipedia,
+            R.layout.welcome_do_upload,
+            R.layout.welcome_dont_upload,
+            R.layout.welcome_image_example,
+            R.layout.welcome_final
+        )
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.kt b/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.kt
deleted file mode 100644
index 3de252af8..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package fr.free.nrw.commons.achievements
-
-/**
- * Represents Achievements class and stores all the parameters
- */
-class Achievements {
-    /**
-     * getter function to get count of unique images used by wiki
-     * @return
-     */
-    /**
-     * setter function to set count of uniques images used by wiki
-     * @param uniqueUsedImages
-     */
-    var uniqueUsedImages = 0
-    private var articlesUsingImages = 0
-    /**
-     * getter function to get count of thanks received
-     * @return
-     */
-    /**
-     * setter function to set count of thanks received
-     * @param thanksReceived
-     */
-    var thanksReceived = 0
-    /**
-     * getter function to get count of featured images
-     * @return
-     */
-    /**
-     * setter function to set count of featured images
-     * @param featuredImages
-     */
-    var featuredImages = 0
-    /**
-     * getter function to get count of images uploaded
-     * @return
-     */
-    /**
-     * setter function to count of images uploaded
-     * @param imagesUploaded
-     */
-    var imagesUploaded = 0
-    private var revertCount = 0
-
-    constructor() {}
-    /**
-     * constructor for achievements class to set its data members
-     * @param uniqueUsedImages
-     * @param articlesUsingImages
-     * @param thanksReceived
-     * @param featuredImages
-     * @param imagesUploaded
-     * @param revertCount
-     */
-    constructor(uniqueUsedImages: Int,
-                articlesUsingImages: Int,
-                thanksReceived: Int,
-                featuredImages: Int,
-                imagesUploaded: Int,
-                revertCount: Int) {
-        this.uniqueUsedImages = uniqueUsedImages
-        this.articlesUsingImages = articlesUsingImages
-        this.thanksReceived = thanksReceived
-        this.featuredImages = featuredImages
-        this.imagesUploaded = imagesUploaded
-        this.revertCount = revertCount
-    }
-
-    /**
-     * used to calculate the percentages of images that haven't been reverted
-     * @return
-     */
-    val notRevertPercentage: Int
-        get() = try {
-            (imagesUploaded - revertCount) * 100 / imagesUploaded
-        } catch (divideByZero: ArithmeticException) {
-            100
-        }
-
-    companion object {
-        /**
-         * Get Achievements object from FeedbackResponse
-         *
-         * @param response
-         * @return
-         */
-        @JvmStatic
-        fun from(response: FeedbackResponse): Achievements {
-            return Achievements(response.uniqueUsedImages,
-                    response.articlesUsingImages,
-                    response.thanksReceived,
-                    response.featuredImages.qualityImages
-                            + response.featuredImages.featuredPicturesOnWikimediaCommons, 0,
-                    response.deletedUploads)
-        }
-    }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java b/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java
deleted file mode 100644
index d12ec942e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java
+++ /dev/null
@@ -1,534 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-import android.accounts.Account;
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.os.Bundle;
-import android.util.DisplayMetrics;
-import android.view.ContextThemeWrapper;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.widget.Toolbar;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.core.content.FileProvider;
-import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
-
-import com.dinuscxj.progressbar.CircleProgressBar;
-
-import org.apache.commons.lang3.StringUtils;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.util.Objects;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-
-
-/**
- * activity for sharing feedback on uploaded activity
- */
-public class AchievementsActivity extends NavigationBaseActivity {
-
-    private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4;
-    private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3;
-
-    private LevelController.LevelInfo levelInfo;
-
-    @BindView(R.id.achievement_badge_image)
-    ImageView imageView;
-    @BindView(R.id.achievement_badge_text)
-    TextView badgeText;
-    @BindView(R.id.achievement_level)
-    TextView levelNumber;
-    @BindView(R.id.toolbar)
-    Toolbar toolbar;
-    @BindView(R.id.thanks_received)
-    TextView thanksReceived;
-    @BindView(R.id.images_uploaded_progressbar)
-    CircleProgressBar imagesUploadedProgressbar;
-    @BindView(R.id.images_used_by_wiki_progress_bar)
-    CircleProgressBar imagesUsedByWikiProgressBar;
-    @BindView(R.id.image_reverts_progressbar)
-    CircleProgressBar imageRevertsProgressbar;
-    @BindView(R.id.image_featured)
-    TextView imagesFeatured;
-    @BindView(R.id.images_revert_limit_text)
-    TextView imagesRevertLimitText;
-    @BindView(R.id.progressBar)
-    ProgressBar progressBar;
-    @BindView(R.id.layout_image_uploaded)
-    RelativeLayout layoutImageUploaded;
-    @BindView(R.id.layout_image_reverts)
-    RelativeLayout layoutImageReverts;
-    @BindView(R.id.layout_image_used_by_wiki)
-    RelativeLayout layoutImageUsedByWiki;
-    @BindView(R.id.layout_statistics)
-    LinearLayout layoutStatistics;
-    @BindView(R.id.images_used_by_wiki_text)
-    TextView imageByWikiText;
-    @BindView(R.id.images_reverted_text)
-    TextView imageRevertedText;
-    @BindView(R.id.images_upload_text_param)
-    TextView imageUploadedText;
-    @BindView(R.id.wikidata_edits)
-    TextView wikidataEditsText;
-
-
-    @Inject
-    SessionManager sessionManager;
-    @Inject
-    OkHttpJsonApiClient okHttpJsonApiClient;
-    MenuItem item;
-
-    private CompositeDisposable compositeDisposable = new CompositeDisposable();
-
-    // To keep track of the number of wiki edits made by a user
-    private int numberOfEdits = 0;
-
-    /**
-     * This method helps in the creation Achievement screen and
-     * dynamically set the size of imageView
-     *
-     * @param savedInstanceState Data bundle
-     */
-    @Override
-    @SuppressLint("StringFormatInvalid")
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_achievements);
-        ButterKnife.bind(this);
-
-        // DisplayMetrics used to fetch the size of the screen
-        DisplayMetrics displayMetrics = new DisplayMetrics();
-        getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
-        int height = displayMetrics.heightPixels;
-        int width = displayMetrics.widthPixels;
-
-        // Used for the setting the size of imageView at runtime
-        ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams)
-                imageView.getLayoutParams();
-        params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO);
-        params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO);
-        imageView.requestLayout();
-
-        setSupportActionBar(toolbar);
-        progressBar.setVisibility(View.VISIBLE);
-
-        hideLayouts();
-        setWikidataEditCount();
-        setAchievements();
-        initDrawer();
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        compositeDisposable.clear();
-    }
-
-    /**
-     * To invoke the AlertDialog on clicking info button
-     */
-    @OnClick(R.id.achievement_info)
-    public void showInfoDialog(){
-        launchAlert(getResources().getString(R.string.Achievements)
-                ,getResources().getString(R.string.achievements_info_message));
-    }
-
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        // Inflate the menu; this adds items to the action bar if it is present.
-        getMenuInflater().inflate(R.menu.menu_about, menu);
-        item=menu.getItem(0);
-        item.setVisible(false);
-        return true;
-    }
-
-    /**
-     * To receive the id of selected item and handle further logic for that selected item
-     */
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        int id = item.getItemId();
-        // take screenshot in form of bitmap and show it in Alert Dialog
-        if (id == R.id.share_app_icon) {
-            View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
-            Bitmap screenShot = Utils.getScreenShot(rootView);
-            showAlert(screenShot);
-        }
-
-        return super.onOptionsItemSelected(item);
-    }
-
-    /**
-     * To take bitmap and store it temporary storage and share it
-     * @param bitmap
-     */
-    void shareScreen(Bitmap bitmap) {
-        try {
-            File file = new File(this.getExternalCacheDir(), "screen.png");
-            FileOutputStream fOut = new FileOutputStream(file);
-            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut);
-            fOut.flush();
-            fOut.close();
-            file.setReadable(true, false);
-            Uri fileUri = FileProvider.getUriForFile(getApplicationContext(), getPackageName()+".provider", file);
-            grantUriPermission(getPackageName(), fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
-            final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
-            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-            intent.putExtra(Intent.EXTRA_STREAM, fileUri);
-            intent.setType("image/png");
-            startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)));
-        } catch (IOException e) {
-            e.printStackTrace();
-        }
-    }
-
-    /**
-     * To call the API to get results in form Single
-     * which then calls parseJson when results are fetched
-     */
-    private void setAchievements() {
-        progressBar.setVisibility(View.VISIBLE);
-        if (checkAccount()) {
-            try{
-
-                compositeDisposable.add(okHttpJsonApiClient
-                        .getAchievements(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
-                        .subscribeOn(Schedulers.io())
-                        .observeOn(AndroidSchedulers.mainThread())
-                        .subscribe(
-                                response -> {
-                                    if (response != null) {
-                                        setUploadCount(Achievements.from(response));
-                                    } else {
-                                        Timber.d("success");
-                                        layoutImageReverts.setVisibility(View.INVISIBLE);
-                                        imageView.setVisibility(View.INVISIBLE);
-                                        // If the number of edits made by the user are more than 150,000
-                                        // in some cases such high number of wiki edit counts cause the 
-                                        // achievements calculator to fail in some cases, for more details
-                                        // refer Issue: #3295
-                                        if (numberOfEdits <= 150000) {
-                                            showSnackBarWithRetry(false);
-                                        } else {
-                                            showSnackBarWithRetry(true);
-                                        }
-                                    }
-                                },
-                                t -> {
-                                    Timber.e(t, "Fetching achievements statistics failed");
-                                    if (numberOfEdits <= 150000) {
-                                        showSnackBarWithRetry(false);
-                                    } else {
-                                        showSnackBarWithRetry(true);
-                                    }
-                                }
-                        ));
-            }
-            catch (Exception e){
-                Timber.d(e+"success");
-            }
-        }
-    }
-
-    /**
-     * To call the API to fetch the count of wiki data edits
-     *  in the form of JavaRx Single object
-     */
-    @SuppressLint("CheckResult")
-    private void setWikidataEditCount() {
-        String userName = sessionManager.getUserName();
-        if (StringUtils.isBlank(userName)) {
-            return;
-        }
-        compositeDisposable.add(okHttpJsonApiClient
-                .getWikidataEdits(userName)
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe(edits -> {
-                    numberOfEdits = edits;
-                    wikidataEditsText.setText(String.valueOf(edits));
-                }, e -> {
-                    Timber.e("Error:" + e);
-                }));
-    }
-
-    /**
-     * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the
-     * listener passed
-     * @param tooManyAchievements if this value is true it means that the number of achievements of the 
-     * user are so high that it wrecks havoc with the Achievements calculator due to which request may time 
-     * out. Well this is the Ultimate Achievement
-     */
-    private void showSnackBarWithRetry(boolean tooManyAchievements) {
-        if (tooManyAchievements) {
-            progressBar.setVisibility(View.GONE);
-            ViewUtil.showDismissibleSnackBar(findViewById(android.R.id.content),
-                    R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements());
-        } else {
-            progressBar.setVisibility(View.GONE);
-            ViewUtil.showDismissibleSnackBar(findViewById(android.R.id.content),
-                    R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements());
-        }
-    }
-
-    /**
-     * Shows a generic error toast when error occurs while loading achievements or uploads
-     */
-    private void onError() {
-        ViewUtil.showLongToast(this, getResources().getString(R.string.error_occurred));
-        progressBar.setVisibility(View.GONE);
-    }
-
-    /**
-     * used to the count of images uploaded by user
-     */
-    private void setUploadCount(Achievements achievements) {
-        if (checkAccount()) {
-            compositeDisposable.add(okHttpJsonApiClient
-                    .getUploadCount(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
-                    .subscribeOn(Schedulers.io())
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribe(
-                            uploadCount -> setAchievementsUploadCount(achievements, uploadCount),
-                            t -> {
-                                Timber.e(t, "Fetching upload count failed");
-                                onError();
-                            }
-                    ));
-        }
-    }
-
-    /**
-     * used to set achievements upload count and call hideProgressbar
-     * @param uploadCount
-     */
-    private void setAchievementsUploadCount(Achievements achievements, int uploadCount) {
-        achievements.setImagesUploaded(uploadCount);
-        hideProgressBar(achievements);
-    }
-
-    /**
-     * used to the uploaded images progressbar
-     * @param uploadCount
-     */
-    private void setUploadProgress(int uploadCount){
-        if (uploadCount==0){
-            setZeroAchievements();
-        }else {
-
-            imagesUploadedProgressbar.setProgress
-                    (100*uploadCount/levelInfo.getMaxUploadCount());
-            imagesUploadedProgressbar.setProgressTextFormatPattern
-                    (uploadCount +"/" + levelInfo.getMaxUploadCount() );
-        }
-
-    }
-
-    private void setZeroAchievements() {
-        AlertDialog.Builder builder=new AlertDialog.Builder(this)
-                .setMessage(getString(R.string.no_achievements_yet))
-                .setPositiveButton(getString(R.string.ok), (dialog, which) -> {
-                });
-        AlertDialog dialog = builder.create();
-        dialog.show();
-        imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
-        imageRevertsProgressbar.setVisibility(View.INVISIBLE);
-        imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
-        imageView.setVisibility(View.INVISIBLE);
-        imageByWikiText.setText(R.string.no_image);
-        imageRevertedText.setText(R.string.no_image_reverted);
-        imageUploadedText.setText(R.string.no_image_uploaded);
-        imageView.setVisibility(View.INVISIBLE);
-
-    }
-
-    /**
-     * used to set the non revert image percentage
-     * @param notRevertPercentage
-     */
-    private void setImageRevertPercentage(int notRevertPercentage){
-        imageRevertsProgressbar.setProgress(notRevertPercentage);
-        String revertPercentage = Integer.toString(notRevertPercentage);
-        imageRevertsProgressbar.setProgressTextFormatPattern(revertPercentage + "%%");
-        imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%");
-    }
-
-    /**
-     * Used the inflate the fetched statistics of the images uploaded by user
-     * and assign badge and level
-     * @param achievements
-     */
-    private void inflateAchievements(Achievements achievements) {
-        thanksReceived.setText(String.valueOf(achievements.getThanksReceived()));
-        imagesUsedByWikiProgressBar.setProgress
-                (100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages());
-        imagesUsedByWikiProgressBar.setProgressTextFormatPattern
-                (achievements.getUniqueUsedImages() + "/" + levelInfo.getMaxUniqueImages());
-        imagesFeatured.setText(String.valueOf(achievements.getFeaturedImages()));
-        String levelUpInfoString = getString(R.string.level).toUpperCase();
-        levelUpInfoString += " " + levelInfo.getLevelNumber();
-        levelNumber.setText(levelUpInfoString);
-        imageView.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge,
-                new ContextThemeWrapper(this, levelInfo.getLevelStyle()).getTheme()));
-        badgeText.setText(Integer.toString(levelInfo.getLevelNumber()));
-    }
-
-    /**
-     * Creates a way to change current activity to AchievementActivity
-     * @param context
-     */
-    public static void startYourself(Context context) {
-        Intent intent = new Intent(context, AchievementsActivity.class);
-        intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        context.startActivity(intent);
-    }
-
-    /**
-     * to hide progressbar
-     */
-    private void hideProgressBar(Achievements achievements) {
-        if (progressBar != null) {
-            levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(),
-                    achievements.getUniqueUsedImages(),
-                    achievements.getNotRevertPercentage());
-            inflateAchievements(achievements);
-            setUploadProgress(achievements.getImagesUploaded());
-            setImageRevertPercentage(achievements.getNotRevertPercentage());
-            progressBar.setVisibility(View.GONE);
-            item.setVisible(true);
-            layoutImageReverts.setVisibility(View.VISIBLE);
-            layoutImageUploaded.setVisibility(View.VISIBLE);
-            layoutImageUsedByWiki.setVisibility(View.VISIBLE);
-            layoutStatistics.setVisibility(View.VISIBLE);
-            imageView.setVisibility(View.VISIBLE);
-            levelNumber.setVisibility(View.VISIBLE);
-        }
-    }
-
-    /**
-     * used to hide the layouts while fetching results from api
-     */
-    private void hideLayouts(){
-        layoutImageUsedByWiki.setVisibility(View.INVISIBLE);
-        layoutImageUploaded.setVisibility(View.INVISIBLE);
-        layoutImageReverts.setVisibility(View.INVISIBLE);
-        layoutStatistics.setVisibility(View.INVISIBLE);
-        imageView.setVisibility(View.INVISIBLE);
-        levelNumber.setVisibility(View.INVISIBLE);
-    }
-
-    /**
-     * It display the alertDialog with Image of screenshot
-     * @param screenshot
-     */
-    public void showAlert(Bitmap screenshot){
-        AlertDialog.Builder alertadd = new AlertDialog.Builder(AchievementsActivity.this);
-        LayoutInflater factory = LayoutInflater.from(AchievementsActivity.this);
-        final View view = factory.inflate(R.layout.image_alert_layout, null);
-        ImageView screenShotImage = view.findViewById(R.id.alert_image);
-        screenShotImage.setImageBitmap(screenshot);
-        TextView shareMessage = view.findViewById(R.id.alert_text);
-        shareMessage.setText(R.string.achievements_share_message);
-        alertadd.setView(view);
-        alertadd.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> shareScreen(screenshot));
-        alertadd.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel());
-        alertadd.show();
-    }
-
-    @OnClick(R.id.images_upload_info)
-    public void showUploadInfo(){
-        launchAlert(getResources().getString(R.string.images_uploaded)
-                ,getResources().getString(R.string.images_uploaded_explanation));
-    }
-
-    @OnClick(R.id.images_reverted_info)
-    public void showRevertedInfo(){
-        launchAlert(getResources().getString(R.string.image_reverts)
-                ,getResources().getString(R.string.images_reverted_explanation));
-    }
-
-    @OnClick(R.id.images_used_by_wiki_info)
-    public void showUsedByWikiInfo(){
-        launchAlert(getResources().getString(R.string.images_used_by_wiki)
-                ,getResources().getString(R.string.images_used_explanation));
-    }
-
-    @OnClick(R.id.images_nearby_info)
-    public void showImagesViaNearbyInfo(){
-        launchAlert(getResources().getString(R.string.statistics_wikidata_edits)
-                ,getResources().getString(R.string.images_via_nearby_explanation));
-    }
-
-    @OnClick(R.id.images_featured_info)
-    public void showFeaturedImagesInfo(){
-        launchAlert(getResources().getString(R.string.statistics_featured)
-                ,getResources().getString(R.string.images_featured_explanation));
-    }
-
-    @OnClick(R.id.thanks_received_info)
-    public void showThanksReceivedInfo(){
-        launchAlert(getResources().getString(R.string.statistics_thanks)
-                ,getResources().getString(R.string.thanks_received_explanation));
-    }
-
-    /**
-     * takes title and message as input to display alerts
-     * @param title
-     * @param message
-     */
-    private void launchAlert(String title, String message){
-        new AlertDialog.Builder(AchievementsActivity.this)
-                .setTitle(title)
-                .setMessage(message)
-                .setCancelable(true)
-                .setPositiveButton(android.R.string.ok, (dialog, id) -> dialog.cancel())
-                .create()
-                .show();
-    }
-
-    /**
-     * check to ensure that user is logged in
-     * @return
-     */
-    private boolean checkAccount(){
-        Account currentAccount = sessionManager.getCurrentAccount();
-        if (currentAccount == null) {
-            Timber.d("Current account is null");
-            ViewUtil.showLongToast(this, getResources().getString(R.string.user_not_logged_in));
-            sessionManager.forceLogin(this);
-            return false;
-        }
-        return true;
-    }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.kt b/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.kt
deleted file mode 100644
index 4f5351e3c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package fr.free.nrw.commons.achievements
-
-import com.google.gson.annotations.SerializedName
-
-/**
-* Represents Featured Images on WikiMedia Commons platform
-* Used by Achievements and FeedbackResponse (objects) of the user
-*/
-class FeaturedImages(
-    @field:SerializedName("Quality_images") val qualityImages: Int,
-    @field:SerializedName("Featured_pictures_on_Wikimedia_Commons") val featuredPicturesOnWikimediaCommons: Int
-)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.kt b/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.kt
deleted file mode 100644
index 8d5d8b7bd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package fr.free.nrw.commons.achievements
-
-/**
-* Represent the Feedback Response of the user
-*/
-data class FeedbackResponse(val uniqueUsedImages: Int,
-                            val articlesUsingImages: Int,
-                            val deletedUploads: Int,
-                            val featuredImages: FeaturedImages,
-                            val thanksReceived: Int,
-                            val user: String)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.kt b/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.kt
deleted file mode 100644
index 772f716bd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package fr.free.nrw.commons.achievements
-
-import fr.free.nrw.commons.R
-
-/**
- * calculates the level of the user
- */
-class LevelController {
-    var level: LevelInfo? = null
-
-    enum class LevelInfo(val levelNumber: Int,
-                         val levelStyle: Int,
-                         val maxUniqueImages: Int,
-                         val maxUploadCount: Int,
-                         val minNonRevertPercentage: Int) {
-        LEVEL_1(1, R.style.LevelOne, 5, 20, 85), LEVEL_2(2, R.style.LevelTwo, 10, 30, 86), LEVEL_3(3, R.style.LevelThree, 15, 40, 87), LEVEL_4(4, R.style.LevelFour, 20, 50, 88), LEVEL_5(5, R.style.LevelFive, 25, 60, 89), LEVEL_6(6, R.style.LevelOne, 30, 70, 90), LEVEL_7(7, R.style.LevelTwo, 40, 80, 90), LEVEL_8(8, R.style.LevelThree, 45, 90, 90), LEVEL_9(9, R.style.LevelFour, 50, 100, 90), LEVEL_10(10, R.style.LevelFive, 55, 110, 90), LEVEL_11(11, R.style.LevelOne, 60, 120, 90), LEVEL_12(12, R.style.LevelTwo, 65, 130, 90), LEVEL_13(13, R.style.LevelThree, 70, 140, 90), LEVEL_14(14, R.style.LevelFour, 75, 150, 90), LEVEL_15(15, R.style.LevelFive, 80, 160, 90), LEVEL_16(16, R.style.LevelOne, 160, 320, 91), LEVEL_17(17, R.style.LevelTwo, 320, 640, 92), LEVEL_18(18, R.style.LevelThree, 640, 1280, 93), LEVEL_19(19, R.style.LevelFour, 1280, 2560, 94), LEVEL_20(20, R.style.LevelFive, 2560, 5120, 95), LEVEL_21(21, R.style.LevelOne, 5120, 10240, 96), LEVEL_22(22, R.style.LevelTwo, 10240, 20480, 97), LEVEL_23(23, R.style.LevelThree, 20480, 40960, 98), LEVEL_24(24, R.style.LevelFour, 40960, 81920, 98), LEVEL_25(25, R.style.LevelFive, 81920, 163840, 98), LEVEL_26(26, R.style.LevelOne, 163840, 327680, 98), LEVEL_27(27, R.style.LevelTwo, 327680, 655360, 98);
-
-        companion object {
-            @JvmStatic
-            fun from(imagesUploaded: Int,
-                     uniqueImagesUsed: Int,
-                     nonRevertRate: Int): LevelInfo {
-                var level = LEVEL_15
-                for (levelInfo in values()) {
-                    if (imagesUploaded < levelInfo.maxUploadCount || uniqueImagesUsed < levelInfo.maxUniqueImages || nonRevertRate < levelInfo.minNonRevertPercentage) {
-                        level = levelInfo
-                        return level
-                    }
-                }
-                return level
-            }
-        }
-
-    }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
new file mode 100644
index 000000000..f49dd7705
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
@@ -0,0 +1,18 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.wikidata.mwapi.MwResponse
+
+/**
+ * Response of the Thanks API.
+ * Context:
+ * The Commons Android app lets you thank other contributors who have uploaded a great picture.
+ * See https://www.mediawiki.org/wiki/Extension:Thanks
+ */
+class MwThankPostResponse : MwResponse() {
+    var result: Result? = null
+
+    inner class Result {
+        var success: Int? = null
+        var recipient: String? = null
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java
deleted file mode 100644
index 46005d006..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import org.wikipedia.csrf.CsrfTokenClient;
-import org.wikipedia.dataclient.Service;
-
-import io.reactivex.Observable;
-
-/**
- * This class acts as a Client to facilitate wiki page editing
- * services to various dependency providing modules such as the Network module, the Review Controller ,etc
- * 
- * The methods provided by this class will post to the Media wiki api
- * documented at: https://commons.wikimedia.org/w/api.php?action=help&modules=edit
- */
-public class PageEditClient {
-
-    private final CsrfTokenClient csrfTokenClient;
-    private final PageEditInterface pageEditInterface;
-    private final Service service;
-
-    public PageEditClient(CsrfTokenClient csrfTokenClient,
-                          PageEditInterface pageEditInterface,
-                          Service service) {
-        this.csrfTokenClient = csrfTokenClient;
-        this.pageEditInterface = pageEditInterface;
-        this.service = service;
-    }
-
-    /**
-     * This method is used when the content of the page is to be replaced by new content received
-     * @param pagetitle   Title of the page to edit
-     * @param text        Holds the page content
-     * @param summary     Edit summary
-     */
-    public Observable edit(String pageTitle, String text, String summary) {
-        try {
-            return pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
-                    .map(editResponse -> editResponse.edit().editSucceeded());
-        } catch (Throwable throwable) {
-            return Observable.just(false);
-        }
-    }
-
-    /**
-     * This method is used when we need to append something to the end of wiki page content
-     * @param pagetitle   Title of the page to edit
-     * @param appendText  The received page content is added to beginning of the page
-     * @param summary     Edit summary
-     */
-    public Observable appendEdit(String pageTitle, String appendText, String summary) {
-        try {
-            return pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
-                    .map(editResponse -> editResponse.edit().editSucceeded());
-        } catch (Throwable throwable) {
-            return Observable.just(false);
-        }
-    }
-
-    /**
-     * This method is used when we need to add something to the starting of the page
-     * @param pagetitle   Title of the page to edit
-     * @param prependText The received page content is added to beginning of the page
-     * @param summary     Edit summary
-     */
-    public Observable prependEdit(String pageTitle, String prependText, String summary) {
-        try {
-            return pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
-                    .map(editResponse -> editResponse.edit().editSucceeded());
-        } catch (Throwable throwable) {
-            return Observable.just(false);
-        }
-    }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
new file mode 100644
index 000000000..a3d6de257
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
@@ -0,0 +1,201 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
+import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
+import io.reactivex.Observable
+import io.reactivex.Single
+
+/**
+ * This class acts as a Client to facilitate wiki page editing
+ * services to various dependency providing modules such as the Network module, the Review Controller, etc.
+ *
+ * The methods provided by this class will post to the Media wiki api
+ * documented at: https://commons.wikimedia.org/w/api.php?action=help&modules=edit
+ */
+class PageEditClient(
+    private val csrfTokenClient: CsrfTokenClient,
+    private val pageEditInterface: PageEditInterface,
+) {
+    /**
+     * Replace the content of a wiki page
+     * @param pageTitle   Title of the page to edit
+     * @param text        Holds the page content
+     * @param summary     Edit summary
+     * @return whether the edit was successful
+     */
+    fun edit(
+        pageTitle: String,
+        text: String,
+        summary: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
+                .map { editResponse ->
+                    editResponse.edit()!!.editSucceeded()
+                }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(false)
+            }
+        }
+
+    /**
+     * Creates a new page with the given title, text, and summary.
+     *
+     * @param pageTitle The title of the page to be created.
+     * @param text      The content of the page in wikitext format.
+     * @param summary   The edit summary for the page creation.
+     * @return An observable that emits true if the page creation succeeded, false otherwise.
+     * @throws InvalidLoginTokenException If an invalid login token is encountered during the process.
+     */
+    fun postCreate(
+        pageTitle: String,
+        text: String,
+        summary: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postCreate(
+                    pageTitle,
+                    summary,
+                    text,
+                    "text/x-wiki",
+                    "wikitext",
+                    true,
+                    true,
+                    csrfTokenClient.getTokenBlocking(),
+                ).map { editResponse ->
+                    editResponse.edit()!!.editSucceeded()
+                }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(false)
+            }
+        }
+
+    /**
+     * Append text to the end of a wiki page
+     * @param pageTitle   Title of the page to edit
+     * @param appendText  The received page content is added to the end of the page
+     * @param summary     Edit summary
+     * @return whether the edit was successful
+     */
+    fun appendEdit(
+        pageTitle: String,
+        appendText: String,
+        summary: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
+                .map { editResponse -> editResponse.edit()!!.editSucceeded() }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(false)
+            }
+        }
+
+    /**
+     * Prepend text to the beginning of a wiki page
+     * @param pageTitle   Title of the page to edit
+     * @param prependText The received page content is added to the beginning of the page
+     * @param summary     Edit summary
+     * @return whether the edit was successful
+     */
+    fun prependEdit(
+        pageTitle: String,
+        prependText: String,
+        summary: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
+                .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(false)
+            }
+        }
+
+    /**
+     * Appends a new section to the wiki page
+     * @param pageTitle   Title of the page to edit
+     * @param sectionTitle Title of the new section that needs to be created
+     * @param sectionText  The page content that is to be added to the section
+     * @param summary     Edit summary
+     * @return whether the edit was successful
+     */
+    fun createNewSection(
+        pageTitle: String,
+        sectionTitle: String,
+        sectionText: String,
+        summary: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking())
+                .map { editResponse -> editResponse.edit()!!.editSucceeded() }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(false)
+            }
+        }
+
+    /**
+     * Set new labels to Wikibase server of commons
+     * @param summary   Edit summary
+     * @param title Title of the page to edit
+     * @param language  Corresponding language of label
+     * @param value label
+     * @return 1 when the edit was successful
+     */
+    fun setCaptions(
+        summary: String,
+        title: String,
+        language: String,
+        value: String,
+    ): Observable =
+        try {
+            pageEditInterface
+                .postCaptions(
+                    summary,
+                    title,
+                    language,
+                    value,
+                    csrfTokenClient.getTokenBlocking(),
+                ).map { it.success }
+        } catch (throwable: Throwable) {
+            if (throwable is InvalidLoginTokenException) {
+                throw throwable
+            } else {
+                Observable.just(0)
+            }
+        }
+
+    /**
+     * Get whole WikiText of required file
+     * @param title : Name of the file
+     * @return Observable
+     */
+    fun getCurrentWikiText(title: String): Single =
+        pageEditInterface.getWikiText(title).map {
+            it
+                .query()
+                ?.pages()
+                ?.get(0)
+                ?.revisions()
+                ?.get(0)
+                ?.content()
+        }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java
deleted file mode 100644
index 8f4faf01f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.edit.Edit;
-
-import io.reactivex.Observable;
-import retrofit2.http.Field;
-import retrofit2.http.FormUrlEncoded;
-import retrofit2.http.Headers;
-import retrofit2.http.POST;
-
-import static org.wikipedia.dataclient.Service.MW_API_PREFIX;
-
-/**
- * This interface facilitates wiki commons page editing services to the Networking module
- * which provides all network related services used by the app.
- * 
- * This interface posts a form encoded request to the wikimedia API 
- * with editing action as argument to edit a particular page
- */
-public interface PageEditInterface {
-
-    /**
-     * This method posts such that the Content which the page
-     * has will be completely replaced by the value being passed to the
-     * "text" field of the encoded form data
-     * @param title    Title of the page to edit. Cannot be used together with pageid.
-     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
-     * @param text     Holds the page content
-     * @param token    A "csrf" token
-     */
-    @FormUrlEncoded
-    @Headers("Cache-Control: no-cache")
-    @POST(MW_API_PREFIX + "action=edit")
-    @NonNull
-    Observable postEdit(@NonNull @Field("title") String title,
-                              @NonNull @Field("summary") String summary,
-                              @NonNull @Field("text") String text,
-                              // NOTE: This csrf shold always be sent as the last field of form data
-                              @NonNull @Field("token") String token);
-
-                              /**
-     * This method posts such that the Content which the page
-     * has will be completely replaced by the value being passed to the
-     * "text" field of the encoded form data
-     * @param title    Title of the page to edit. Cannot be used together with pageid.
-     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
-     * @param text     The received page content is added to beginning of the page
-     * @param token    A "csrf" token
-     */
-    @FormUrlEncoded
-    @Headers("Cache-Control: no-cache")
-    @POST(MW_API_PREFIX + "action=edit")
-    @NonNull Observable postAppendEdit(@NonNull @Field("title") String title,
-                                             @NonNull @Field("summary") String summary,
-                                             @NonNull @Field("appendtext") String text,
-                                             @NonNull @Field("token") String token);
-
-                                             /**
-     * This method posts such that the Content which the page
-     * has will be completely replaced by the value being passed to the
-     * "text" field of the encoded form data
-     * @param title    Title of the page to edit. Cannot be used together with pageid.
-     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
-     * @param text     The received page content is added to beginning of the page
-     * @param token    A "csrf" token
-     */
-    @FormUrlEncoded
-    @Headers("Cache-Control: no-cache")
-    @POST(MW_API_PREFIX + "action=edit")
-    @NonNull Observable postPrependEdit(@NonNull @Field("title") String title,
-                                              @NonNull @Field("summary") String summary,
-                                              @NonNull @Field("prependtext") String text,
-                                              @NonNull @Field("token") String token);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt
new file mode 100644
index 000000000..5e2651039
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt
@@ -0,0 +1,141 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.model.Entities
+import fr.free.nrw.commons.wikidata.model.edit.Edit
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Observable
+import io.reactivex.Single
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+/**
+ * This interface facilitates wiki commons page editing services to the Networking module
+ * which provides all network related services used by the app.
+ *
+ * This interface posts a form encoded request to the wikimedia API
+ * with editing action as argument to edit a particular page
+ */
+interface PageEditInterface {
+    /**
+     * This method posts such that the Content which the page
+     * has will be completely replaced by the value being passed to the
+     * "text" field of the encoded form data
+     * @param title    Title of the page to edit. Cannot be used together with pageid.
+     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
+     * @param text     Holds the page content
+     * @param token    A "csrf" token
+     */
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=edit")
+    fun postEdit(
+        @Field("title") title: String,
+        @Field("summary") summary: String,
+        @Field("text") text: String,
+        // NOTE: This csrf shold always be sent as the last field of form data
+        @Field("token") token: String,
+    ): Observable
+
+    /**
+     * This method creates or edits a page for nearby items.
+     *
+     * @param title           Title of the page to edit. Cannot be used together with pageid.
+     * @param summary         Edit summary. Also used as the section title when section=new and sectiontitle is not set.
+     * @param text            Text of the page.
+     * @param contentformat   Format of the content (e.g., "text/x-wiki").
+     * @param contentmodel    Model of the content (e.g., "wikitext").
+     * @param minor           Whether the edit is a minor edit.
+     * @param recreate        Whether to recreate the page if it does not exist.
+     * @param token           A "csrf" token. This should always be sent as the last field of form data.
+     */
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=edit")
+    fun postCreate(
+        @Field("title") title: String,
+        @Field("summary") summary: String,
+        @Field("text") text: String,
+        @Field("contentformat") contentformat: String,
+        @Field("contentmodel") contentmodel: String,
+        @Field("minor") minor: Boolean,
+        @Field("recreate") recreate: Boolean,
+        // NOTE: This csrf shold always be sent as the last field of form data
+        @Field("token") token: String,
+    ): Observable
+
+    /**
+     * This method posts such that the Content which the page
+     * has will be appended with the value being passed to the
+     * "appendText" field of the encoded form data
+     * @param title    Title of the page to edit. Cannot be used together with pageid.
+     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
+     * @param appendText Text to add to the end of the page
+     * @param token    A "csrf" token
+     */
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=edit")
+    fun postAppendEdit(
+        @Field("title") title: String,
+        @Field("summary") summary: String,
+        @Field("appendtext") appendText: String,
+        @Field("token") token: String,
+    ): Observable
+
+    /**
+     * This method posts such that the Content which the page
+     * has will be prepended with the value being passed to the
+     * "prependText" field of the encoded form data
+     * @param title    Title of the page to edit. Cannot be used together with pageid.
+     * @param summary  Edit summary. Also section title when section=new and sectiontitle is not set
+     * @param prependText Text to add to the beginning of the page
+     * @param token    A "csrf" token
+     */
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=edit")
+    fun postPrependEdit(
+        @Field("title") title: String,
+        @Field("summary") summary: String,
+        @Field("prependtext") prependText: String,
+        @Field("token") token: String,
+    ): Observable
+
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=edit§ion=new")
+    fun postNewSection(
+        @Field("title") title: String,
+        @Field("summary") summary: String,
+        @Field("sectiontitle") sectionTitle: String,
+        @Field("text") sectionText: String,
+        @Field("token") token: String,
+    ): Observable
+
+    @FormUrlEncoded
+    @Headers("Cache-Control: no-cache")
+    @POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
+    fun postCaptions(
+        @Field("summary") summary: String,
+        @Field("title") title: String,
+        @Field("language") language: String,
+        @Field("value") value: String,
+        @Field("token") token: String,
+    ): Observable
+
+    /**
+     * Gets the wiki text for the provided file name.
+     *
+     * @param title The title (name) of the file to fetch wiki text for.
+     * @return A Single emitting the wiki query response.
+     */
+    @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=")
+    fun getWikiText(
+        @Query("titles") title: String,
+    ): Single
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java
deleted file mode 100644
index aa606a93a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package fr.free.nrw.commons.actions;
-
-import org.wikipedia.csrf.CsrfTokenClient;
-import org.wikipedia.dataclient.Service;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.CommonsApplication;
-import io.reactivex.Observable;
-
-/**
- * Facilitates the Wkikimedia Thanks api extention, as described in the 
- * api documentation: "The Thanks extension includes an API for sending thanks"
- * 
- * In simple terms this class is used by a user to thank someone for adding 
- * contribution to the commons platform
- */
-@Singleton
-public class ThanksClient {
-
-    private final CsrfTokenClient csrfTokenClient;
-    private final Service service;
-
-    @Inject
-    public ThanksClient(@Named("commons-csrf") CsrfTokenClient csrfTokenClient,
-                        @Named("commons-service") Service service) {
-        this.csrfTokenClient = csrfTokenClient;
-        this.service = service;
-    }
-
-    /**
-     * Handles the Thanking logic
-     * @param revesionID The revision ID you would like to thank someone for
-     * @return if thanks was successfully sent to intended recepient, returned as a boolean observable
-     */
-    public Observable thank(long revisionId) {
-        try {
-            return service.thank(String.valueOf(revisionId), null,
-                    csrfTokenClient.getTokenBlocking(),
-                    CommonsApplication.getInstance().getUserAgent())
-                    .map(mwQueryResponse -> mwQueryResponse.getSuccessVal() == 1);
-        } catch (Throwable throwable) {
-            return Observable.just(false);
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt
new file mode 100644
index 000000000..1dcf93edf
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt
@@ -0,0 +1,46 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
+import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
+import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF
+import io.reactivex.Observable
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Client for the Wkikimedia Thanks API extension
+ * Thanks are used by a user to show gratitude to another user for their contributions
+ */
+@Singleton
+class ThanksClient
+    @Inject
+    constructor(
+        @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient,
+        private val service: ThanksInterface,
+    ) {
+        /**
+         * Thanks a user for a particular revision
+         * @param revisionId The revision ID the user would like to thank someone for
+         * @return if thanks was successfully sent to intended recipient
+         */
+        fun thank(revisionId: Long): Observable =
+            try {
+                service
+                    .thank(
+                        revisionId.toString(), // Rev
+                        null, // Log
+                        csrfTokenClient.getTokenBlocking(), // Token
+                        CommonsApplication.instance.userAgent, // Source
+                    ).map { mwThankPostResponse ->
+                        mwThankPostResponse.result?.success == 1
+                    }
+            } catch (throwable: Throwable) {
+                if (throwable is InvalidLoginTokenException) {
+                    Observable.error(throwable)
+                } else {
+                    Observable.just(false)
+                }
+            }
+    }
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt
new file mode 100644
index 000000000..62934d0f2
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt
@@ -0,0 +1,24 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import io.reactivex.Observable
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.POST
+
+/**
+ * Thanks API.
+ * Context:
+ * The Commons Android app lets you thank another contributor who has uploaded a great picture.
+ * See https://www.mediawiki.org/wiki/Extension:Thanks
+ */
+interface ThanksInterface {
+    @FormUrlEncoded
+    @POST(MW_API_PREFIX + "action=thank")
+    fun thank(
+        @Field("rev") rev: String?,
+        @Field("log") log: String?,
+        @Field("token") token: String,
+        @Field("source") source: String?,
+    ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt
new file mode 100644
index 000000000..0710e2551
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt
@@ -0,0 +1,218 @@
+package fr.free.nrw.commons.activity
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.webkit.ConsoleMessage
+import android.webkit.CookieManager
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * SingleWebViewActivity is a reusable activity webView based on a given url(initial url) and
+ * closes itself when a specified success URL is reached to success url.
+ */
+class SingleWebViewActivity : ComponentActivity() {
+    @Inject
+    lateinit var cookieJar: CommonsCookieJar
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val url = intent.getStringExtra(VANISH_ACCOUNT_URL)
+        val successUrl = intent.getStringExtra(VANISH_ACCOUNT_SUCCESS_URL)
+        if (url == null || successUrl == null) {
+            finish()
+            return
+        }
+        ApplicationlessInjection
+            .getInstance(applicationContext)
+            .commonsApplicationComponent
+            .inject(this)
+        setCookies(url)
+        enableEdgeToEdge()
+        setContent {
+            Scaffold(
+                topBar = {
+                    TopAppBar(
+                        modifier = Modifier,
+                        title = { Text(getString(R.string.vanish_account)) },
+                        navigationIcon = {
+                            IconButton(
+                                onClick = {
+                                    // Close the WebView Activity if the user taps the back button
+                                    finish()
+                                },
+                            ) {
+                                Icon(
+                                    imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                                    // TODO("Add contentDescription)
+                                    contentDescription = ""
+                                )
+                            }
+                        }
+                    )
+                },
+                content = {
+                    WebViewComponent(
+                        url = url,
+                        successUrl = successUrl,
+                        onSuccess = {
+                            //Redirect the user to login screen like we do when the user logout's
+                            val app = applicationContext as CommonsApplication
+                            app.clearApplicationData(
+                                applicationContext,
+                                ActivityLogoutListener(activity = this, ctx = applicationContext)
+                            )
+                            finish()
+                        },
+                        modifier = Modifier
+                            .fillMaxSize()
+                            .padding(it)
+                    )
+                }
+            )
+        }
+    }
+
+
+    /**
+     * @param url The initial URL which we are loading in the WebView.
+     * @param successUrl The URL that, when reached, triggers the `onSuccess` callback.
+     * @param onSuccess A callback that is invoked when the current url of webView is successUrl.
+     * This is used when we want to close when the webView once a success url is hit.
+     * @param modifier An optional [Modifier] to customize the layout or appearance of the WebView.
+     */
+    @SuppressLint("SetJavaScriptEnabled")
+    @Composable
+    private fun WebViewComponent(
+        url: String,
+        successUrl: String,
+        onSuccess: () -> Unit,
+        modifier: Modifier = Modifier
+    ) {
+        val webView = remember { mutableStateOf(null) }
+        AndroidView(
+            modifier = modifier,
+            factory = {
+                WebView(it).apply {
+                    settings.apply {
+                        javaScriptEnabled = true
+                        domStorageEnabled = true
+                        javaScriptCanOpenWindowsAutomatically = true
+
+                    }
+                    webViewClient = object : WebViewClient() {
+                        override fun shouldOverrideUrlLoading(
+                            view: WebView?,
+                            request: WebResourceRequest?
+                        ): Boolean {
+
+                            request?.url?.let { url ->
+                                Timber.d("URL Loading: $url")
+                                if (url.toString() == successUrl) {
+                                    Timber.d("Success URL detected. Closing WebView.")
+                                    onSuccess() // Close the activity
+                                    return true
+                                }
+                                return false
+                            }
+                            return false
+                        }
+
+                        override fun onPageFinished(view: WebView?, url: String?) {
+                            super.onPageFinished(view, url)
+                            setCookies(url.orEmpty())
+                        }
+
+                    }
+
+                    webChromeClient = object : WebChromeClient() {
+                        override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+                            Timber.d("%s%s",
+                                "Console: ${message.message()} -- From line ",
+                                "${message.lineNumber()} of ${message.sourceId()}")
+                            return true
+                        }
+                    }
+
+                    loadUrl(url)
+                }
+            },
+            update = {
+                webView.value = it
+            }
+        )
+
+    }
+
+    /**
+     * Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`.
+     *
+     * @param url The URL for which cookies need to be set.
+     */
+    private fun setCookies(url: String) {
+        CookieManager.getInstance().let {
+            val cookies = cookieJar.loadForRequest(url.toHttpUrl())
+            for (cookie in cookies) {
+                it.setCookie(url, cookie.toString())
+            }
+        }
+    }
+
+    companion object {
+        private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
+        private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"
+
+        /**
+         * Launch the WebViewActivity with the specified URL and success URL.
+         * @param context The context from which the activity is launched.
+         * @param url The initial URL to load in the WebView.
+         * @param successUrl The URL that triggers the WebView to close when matched.
+         */
+        fun showWebView(
+            context: Context,
+            url: String,
+            successUrl: String
+        ) {
+            val intent = Intent(
+                context,
+                SingleWebViewActivity::class.java
+            ).apply {
+                putExtra(VANISH_ACCOUNT_URL, url)
+                putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl)
+            }
+            context.startActivity(intent)
+        }
+    }
+}
+
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
deleted file mode 100644
index 53903769d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.content.Context;
-
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-import timber.log.Timber;
-
-public class AccountUtil {
-
-    public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
-
-    public AccountUtil() {
-    }
-
-    /**
-     * @return Account|null
-     */
-    @Nullable
-    public static Account account(Context context) {
-        try {
-            Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE);
-            if (accounts.length > 0) {
-                return accounts[0];
-            }
-        } catch (SecurityException e) {
-            Timber.e(e);
-        }
-        return null;
-    }
-
-    @Nullable
-    public static String getUserName(Context context) {
-        Account account = account(context);
-        return account == null ? null : account.name;
-    }
-
-    private static AccountManager accountManager(Context context) {
-        return AccountManager.get(context);
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
new file mode 100644
index 000000000..aa86cd0d8
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
@@ -0,0 +1,24 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import timber.log.Timber
+
+const val AUTH_TOKEN_TYPE: String = "CommonsAndroid"
+
+fun getUserName(context: Context): String? {
+    return account(context)?.name
+}
+
+@VisibleForTesting
+fun account(context: Context): Account? = try {
+    val accountManager = AccountManager.get(context)
+    val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
+    if (accounts.isNotEmpty()) accounts[0] else null
+} catch (e: SecurityException) {
+    Timber.e(e)
+    null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
deleted file mode 100644
index ed1bd29c7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
+++ /dev/null
@@ -1,459 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AccountAuthenticatorActivity;
-import android.app.ProgressDialog;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.view.KeyEvent;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.inputmethod.InputMethodManager;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import androidx.annotation.ColorRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.app.AppCompatDelegate;
-import androidx.core.app.NavUtils;
-import androidx.core.content.ContextCompat;
-
-import com.google.android.material.textfield.TextInputLayout;
-
-import org.wikipedia.AppAdapter;
-import org.wikipedia.dataclient.ServiceFactory;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-import org.wikipedia.login.LoginClient;
-import org.wikipedia.login.LoginClient.LoginCallback;
-import org.wikipedia.login.LoginResult;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import butterknife.OnEditorAction;
-import butterknife.OnFocusChange;
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.WelcomeActivity;
-import fr.free.nrw.commons.contributions.MainActivity;
-import fr.free.nrw.commons.di.ApplicationlessInjection;
-import fr.free.nrw.commons.explore.categories.ExploreActivity;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.utils.ConfigUtils;
-import fr.free.nrw.commons.utils.SystemThemeUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.disposables.CompositeDisposable;
-import retrofit2.Call;
-import retrofit2.Callback;
-import retrofit2.Response;
-import timber.log.Timber;
-
-import static android.view.KeyEvent.KEYCODE_ENTER;
-import static android.view.View.VISIBLE;
-import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
-import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_WIKI_SITE;
-
-public class LoginActivity extends AccountAuthenticatorActivity {
-
-    @Inject
-    SessionManager sessionManager;
-
-    @Inject
-    @Named(NAMED_COMMONS_WIKI_SITE)
-    WikiSite commonsWikiSite;
-
-    @Inject
-    @Named("default_preferences")
-    JsonKvStore applicationKvStore;
-
-    @Inject
-    LoginClient loginClient;
-
-    @Inject
-    SystemThemeUtils systemThemeUtils;
-
-    @BindView(R.id.login_button)
-    Button loginButton;
-
-    @BindView(R.id.login_username)
-    EditText usernameEdit;
-
-    @BindView(R.id.login_password)
-    EditText passwordEdit;
-
-    @BindView(R.id.login_two_factor)
-    EditText twoFactorEdit;
-
-    @BindView(R.id.error_message_container)
-    ViewGroup errorMessageContainer;
-
-    @BindView(R.id.error_message)
-    TextView errorMessage;
-
-    @BindView(R.id.login_credentials)
-    TextView loginCredentials;
-
-    @BindView(R.id.two_factor_container)
-    TextInputLayout twoFactorContainer;
-
-    ProgressDialog progressDialog;
-    private AppCompatDelegate delegate;
-    private LoginTextWatcher textWatcher = new LoginTextWatcher();
-    private CompositeDisposable compositeDisposable = new CompositeDisposable();
-    private Call loginToken;
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        ApplicationlessInjection
-                .getInstance(this.getApplicationContext())
-                .getCommonsApplicationComponent()
-                .inject(this);
-
-        boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode();
-        setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
-        getDelegate().installViewFactory();
-        getDelegate().onCreate(savedInstanceState);
-
-        setContentView(R.layout.activity_login);
-
-        ButterKnife.bind(this);
-
-        usernameEdit.addTextChangedListener(textWatcher);
-        passwordEdit.addTextChangedListener(textWatcher);
-        twoFactorEdit.addTextChangedListener(textWatcher);
-
-        if (ConfigUtils.isBetaFlavour()) {
-            loginCredentials.setText(getString(R.string.login_credential));
-        } else {
-            loginCredentials.setVisibility(View.GONE);
-        }
-    }
-
-    @OnFocusChange(R.id.login_password)
-    void onPasswordFocusChanged(View view, boolean hasFocus) {
-        if (!hasFocus) {
-            ViewUtil.hideKeyboard(view);
-        }
-    }
-
-    @OnEditorAction(R.id.login_password)
-    boolean onEditorAction(int actionId, KeyEvent keyEvent) {
-        if (loginButton.isEnabled()) {
-            if (actionId == IME_ACTION_DONE) {
-                performLogin();
-                return true;
-            } else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
-                performLogin();
-                return true;
-            }
-        }
-        return false;
-    }
-
-
-    @OnClick(R.id.skip_login)
-    void skipLogin() {
-        new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
-                .setMessage(R.string.skip_login_message)
-                .setCancelable(false)
-                .setPositiveButton(R.string.yes, (dialog, which) -> {
-                    dialog.cancel();
-                    performSkipLogin();
-                })
-                .setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel())
-                .show();
-    }
-
-    @OnClick(R.id.forgot_password)
-    void forgotPassword() {
-        Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
-    }
-
-    @OnClick(R.id.about_privacy_policy)
-    void onPrivacyPolicyClicked() {
-        Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
-    }
-
-    @OnClick(R.id.sign_up_button)
-    void signUp() {
-        Intent intent = new Intent(this, SignupActivity.class);
-        startActivity(intent);
-    }
-
-    @Override
-    protected void onPostCreate(Bundle savedInstanceState) {
-        super.onPostCreate(savedInstanceState);
-        getDelegate().onPostCreate(savedInstanceState);
-    }
-
-    @Override
-    protected void onResume() {
-        super.onResume();
-        if (applicationKvStore.getBoolean("firstrun", true)) {
-            WelcomeActivity.startYourself(this);
-        }
-
-        if (sessionManager.getCurrentAccount() != null
-                && sessionManager.isUserLoggedIn()) {
-            applicationKvStore.putBoolean("login_skipped", false);
-            startMainActivity();
-        }
-
-        if (applicationKvStore.getBoolean("login_skipped", false)) {
-            performSkipLogin();
-        }
-
-    }
-
-    @Override
-    protected void onDestroy() {
-        compositeDisposable.clear();
-        try {
-            // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
-            if (progressDialog != null && progressDialog.isShowing()) {
-                progressDialog.dismiss();
-            }
-        } catch (Exception e) {
-            e.printStackTrace();
-        }
-        usernameEdit.removeTextChangedListener(textWatcher);
-        passwordEdit.removeTextChangedListener(textWatcher);
-        twoFactorEdit.removeTextChangedListener(textWatcher);
-        delegate.onDestroy();
-        if(null!=loginClient) {
-            loginClient.cancel();
-        }
-        super.onDestroy();
-    }
-
-    @OnClick(R.id.login_button)
-    public void performLogin() {
-        Timber.d("Login to start!");
-        final String username = usernameEdit.getText().toString();
-        final String rawUsername = usernameEdit.getText().toString().trim();
-        final String password = passwordEdit.getText().toString();
-        String twoFactorCode = twoFactorEdit.getText().toString();
-
-        showLoggingProgressBar();
-        doLogin(username, password, twoFactorCode);
-    }
-
-    private void doLogin(String username, String password, String twoFactorCode) {
-        progressDialog.show();
-        loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
-        loginToken.enqueue(
-                new Callback() {
-                    @Override
-                    public void onResponse(Call call,
-                                           Response response) {
-                        loginClient.login(commonsWikiSite, username, password, null, twoFactorCode,
-                                response.body().query().loginToken(), new LoginCallback() {
-                                    @Override
-                                    public void success(@NonNull LoginResult result) {
-                                        Timber.d("Login Success");
-                                        onLoginSuccess(result);
-                                    }
-
-                                    @Override
-                                    public void twoFactorPrompt(@NonNull Throwable caught,
-                                                                @Nullable String token) {
-                                        Timber.d("Requesting 2FA prompt");
-                                        hideProgress();
-                                        askUserForTwoFactorAuth();
-                                    }
-
-                                    @Override
-                                    public void passwordResetPrompt(@Nullable String token) {
-                                        Timber.d("Showing password reset prompt");
-                                        hideProgress();
-                                        showPasswordResetPrompt();
-                                    }
-
-                                    @Override
-                                    public void error(@NonNull Throwable caught) {
-                                        Timber.e(caught);
-                                        hideProgress();
-                                        showMessageAndCancelDialog(caught.getLocalizedMessage());
-                                    }
-                                });
-                    }
-
-                    @Override
-                    public void onFailure(Call call, Throwable t) {
-                        Timber.e(t);
-                        showMessageAndCancelDialog(t.getLocalizedMessage());
-                    }
-                });
-
-    }
-
-    private void hideProgress() {
-        progressDialog.dismiss();
-    }
-
-    private void showPasswordResetPrompt() {
-        showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword));
-    }
-
-
-    /**
-     * This function is called when user skips the login.
-     * It redirects the user to Explore Activity.
-     */
-    private void performSkipLogin() {
-        applicationKvStore.putBoolean("login_skipped", true);
-        ExploreActivity.startYourself(this);
-        finish();
-    }
-
-    private void showLoggingProgressBar() {
-        progressDialog = new ProgressDialog(this);
-        progressDialog.setIndeterminate(true);
-        progressDialog.setTitle(getString(R.string.logging_in_title));
-        progressDialog.setMessage(getString(R.string.logging_in_message));
-        progressDialog.setCanceledOnTouchOutside(false);
-        progressDialog.show();
-    }
-
-    private void onLoginSuccess(LoginResult loginResult) {
-        if (!progressDialog.isShowing()) {
-            // no longer attached to activity!
-            return;
-        }
-        sessionManager.setUserLoggedIn(true);
-        AppAdapter.get().updateAccount(loginResult);
-        progressDialog.dismiss();
-        showSuccessAndDismissDialog();
-        startMainActivity();
-    }
-
-    @Override
-    protected void onStart() {
-        super.onStart();
-        delegate.onStart();
-    }
-
-    @Override
-    protected void onStop() {
-        super.onStop();
-        delegate.onStop();
-    }
-
-    @Override
-    protected void onPostResume() {
-        super.onPostResume();
-        getDelegate().onPostResume();
-    }
-
-    @Override
-    public void setContentView(View view, ViewGroup.LayoutParams params) {
-        getDelegate().setContentView(view, params);
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case android.R.id.home:
-                NavUtils.navigateUpFromSameTask(this);
-                return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    @Override
-    @NonNull
-    public MenuInflater getMenuInflater() {
-        return getDelegate().getMenuInflater();
-    }
-
-    public void askUserForTwoFactorAuth() {
-        progressDialog.dismiss();
-        twoFactorContainer.setVisibility(VISIBLE);
-        twoFactorEdit.setVisibility(VISIBLE);
-        twoFactorEdit.requestFocus();
-        InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
-        imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
-        showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
-    }
-
-    public void showMessageAndCancelDialog(@StringRes int resId) {
-        showMessage(resId, R.color.secondaryDarkColor);
-        if (progressDialog != null) {
-            progressDialog.cancel();
-        }
-    }
-
-    public void showMessageAndCancelDialog(String error) {
-        showMessage(error, R.color.secondaryDarkColor);
-        if (progressDialog != null) {
-            progressDialog.cancel();
-        }
-    }
-
-    public void showSuccessAndDismissDialog() {
-        showMessage(R.string.login_success, R.color.primaryDarkColor);
-        progressDialog.dismiss();
-    }
-
-    public void startMainActivity() {
-        NavigationBaseActivity.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        finish();
-    }
-
-    private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
-        errorMessage.setText(getString(resId));
-        errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
-        errorMessageContainer.setVisibility(VISIBLE);
-    }
-
-    private void showMessage(String message, @ColorRes int colorResId) {
-        errorMessage.setText(message);
-        errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
-        errorMessageContainer.setVisibility(VISIBLE);
-    }
-
-    private AppCompatDelegate getDelegate() {
-        if (delegate == null) {
-            delegate = AppCompatDelegate.create(this, null);
-        }
-        return delegate;
-    }
-
-    private class LoginTextWatcher implements TextWatcher {
-        @Override
-        public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
-        }
-
-        @Override
-        public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
-        }
-
-        @Override
-        public void afterTextChanged(Editable editable) {
-            boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
-                    && (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
-            loginButton.setEnabled(enabled);
-        }
-    }
-
-    public static void startYourself(Context context) {
-        Intent intent = new Intent(context, LoginActivity.class);
-        context.startActivity(intent);
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
new file mode 100644
index 000000000..0c9901b56
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
@@ -0,0 +1,489 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AccountAuthenticatorActivity
+import android.app.ProgressDialog
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.app.NavUtils
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.ActivityLoginBinding
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.AbstractTextWatcher
+import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+import fr.free.nrw.commons.utils.SystemThemeUtils
+import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
+import fr.free.nrw.commons.utils.handleKeyboardInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import io.reactivex.disposables.CompositeDisposable
+import timber.log.Timber
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Named
+
+class LoginActivity : AccountAuthenticatorActivity() {
+    @Inject
+    lateinit var sessionManager: SessionManager
+
+    @Inject
+    @field:Named("default_preferences")
+    lateinit var applicationKvStore: JsonKvStore
+
+    @Inject
+    lateinit var loginClient: LoginClient
+
+    @Inject
+    lateinit var systemThemeUtils: SystemThemeUtils
+
+    private var binding: ActivityLoginBinding? = null
+    private var progressDialog: ProgressDialog? = null
+    private val textWatcher = AbstractTextWatcher(::onTextChanged)
+    private val compositeDisposable = CompositeDisposable()
+    private val delegate: AppCompatDelegate by lazy {
+        AppCompatDelegate.create(this, null)
+    }
+    private var lastLoginResult: LoginResult? = null
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        ApplicationlessInjection
+            .getInstance(this.applicationContext)
+            .commonsApplicationComponent
+            .inject(this)
+
+        val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
+        setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
+        delegate.installViewFactory()
+        delegate.onCreate(savedInstanceState)
+
+        WindowCompat.getInsetsController(window, window.decorView)
+            .isAppearanceLightStatusBars = !isDarkTheme
+
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+
+        binding = ActivityLoginBinding.inflate(layoutInflater)
+        applyEdgeToEdgeAllInsets(binding!!.root)
+        binding!!.root.handleKeyboardInsets()
+        with(binding!!) {
+            setContentView(root)
+
+            loginUsername.addTextChangedListener(textWatcher)
+            loginPassword.addTextChangedListener(textWatcher)
+            loginTwoFactor.addTextChangedListener(textWatcher)
+
+            skipLogin.setOnClickListener { skipLogin() }
+            forgotPassword.setOnClickListener { forgotPassword() }
+            aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
+            signUpButton.setOnClickListener { signUp() }
+            loginButton.setOnClickListener { performLogin() }
+            loginPassword.setOnEditorActionListener { textView, actionId, keyEvent ->
+                if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+                    if (actionId == EditorInfo.IME_ACTION_NEXT && lastLoginResult != null) {
+                        askUserForTwoFactorAuthWithKeyboard()
+                        true
+                    } else {
+                        performLogin()
+                        true
+                    }
+                } else {
+                    false
+                }
+            }
+
+            loginPassword.onFocusChangeListener =
+                View.OnFocusChangeListener(::onPasswordFocusChanged)
+
+            if (isBetaFlavour) {
+                loginCredentials.text = getString(R.string.login_credential)
+            } else {
+                loginCredentials.visibility = View.GONE
+            }
+
+            intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
+                showMessage(it, R.color.secondaryDarkColor)
+            }
+
+            intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
+                loginUsername.setText(it)
+            }
+        }
+    }
+
+    @VisibleForTesting
+    fun askUserForTwoFactorAuthWithKeyboard() {
+        if (binding == null) {
+            Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuthWithKeyboard")
+            binding = ActivityLoginBinding.inflate(layoutInflater)
+            setContentView(binding!!.root)
+        }
+        progressDialog!!.dismiss()
+        if (binding != null) {
+            with(binding!!) {
+                twoFactorContainer.visibility = View.VISIBLE
+                twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+                loginTwoFactor.visibility = View.VISIBLE
+                loginTwoFactor.requestFocus()
+
+                val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+                imm.showSoftInput(loginTwoFactor, InputMethodManager.SHOW_IMPLICIT)
+
+                loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+                    if (actionId == EditorInfo.IME_ACTION_DONE ||
+                        (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+                        performLogin()
+                        true
+                    } else {
+                        false
+                    }
+                }
+            }
+        } else {
+            Timber.e("Binding is null in askUserForTwoFactorAuthWithKeyboard after reinitialization attempt")
+        }
+        showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+    }
+    override fun onPostCreate(savedInstanceState: Bundle?) {
+        super.onPostCreate(savedInstanceState)
+        delegate.onPostCreate(savedInstanceState)
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
+            applicationKvStore.putBoolean("login_skipped", false)
+            startMainActivity()
+        }
+
+        if (applicationKvStore.getBoolean("login_skipped", false)) {
+            performSkipLogin()
+        }
+    }
+
+    override fun onDestroy() {
+        compositeDisposable.clear()
+        try {
+            // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
+            if (progressDialog?.isShowing == true) {
+                progressDialog!!.dismiss()
+            }
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+        with(binding!!) {
+            loginUsername.removeTextChangedListener(textWatcher)
+            loginPassword.removeTextChangedListener(textWatcher)
+            loginTwoFactor.removeTextChangedListener(textWatcher)
+        }
+        delegate.onDestroy()
+        loginClient.cancel()
+        binding = null
+        super.onDestroy()
+    }
+
+    override fun onStart() {
+        super.onStart()
+        delegate.onStart()
+    }
+
+    override fun onStop() {
+        super.onStop()
+        delegate.onStop()
+    }
+
+    override fun onPostResume() {
+        super.onPostResume()
+        delegate.onPostResume()
+    }
+
+    override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
+        delegate.setContentView(view, params)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        when (item.itemId) {
+            android.R.id.home -> {
+                NavUtils.navigateUpFromSameTask(this)
+                return true
+            }
+        }
+        return super.onOptionsItemSelected(item)
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        // if progressDialog is visible during the configuration change  then store state as  true else false so that
+        // we maintain visibility of progressDialog after configuration change
+        if (progressDialog != null && progressDialog!!.isShowing) {
+            outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
+        } else {
+            outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
+        }
+        outState.putString(
+            SAVE_ERROR_MESSAGE,
+            binding!!.errorMessage.text.toString()
+        ) //Save the errorMessage
+        outState.putString(
+            SAVE_USERNAME,
+            binding!!.loginUsername.text.toString()
+        ) // Save the username
+        outState.putString(
+            SAVE_PASSWORD,
+            binding!!.loginPassword.text.toString()
+        ) // Save the password
+    }
+
+    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+        super.onRestoreInstanceState(savedInstanceState)
+        binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
+        binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
+        if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
+            performLogin()
+        }
+        val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
+        if (sessionManager.isUserLoggedIn) {
+            showMessage(R.string.login_success, R.color.primaryDarkColor)
+        } else {
+            showMessage(errorMessage, R.color.secondaryDarkColor)
+        }
+    }
+
+    /**
+     * Hides the keyboard if the user's focus is not on the password (hasFocus is false).
+     * @param view The keyboard
+     * @param hasFocus Set to true if the keyboard has focus
+     */
+    private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
+        if (!hasFocus) {
+            hideKeyboard(view)
+        }
+    }
+
+    private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
+        if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+            performLogin()
+            true
+        } else false
+
+    private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
+        actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
+
+    private fun skipLogin() {
+        AlertDialog.Builder(this)
+            .setTitle(R.string.skip_login_title)
+            .setMessage(R.string.skip_login_message)
+            .setCancelable(false)
+            .setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
+                dialog.cancel()
+                performSkipLogin()
+            }
+            .setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
+                dialog.cancel()
+            }
+            .show()
+    }
+
+    private fun forgotPassword() =
+        handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
+
+    private fun onPrivacyPolicyClicked() =
+        handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
+
+    private fun signUp() =
+        startActivity(Intent(this, SignupActivity::class.java))
+
+    @VisibleForTesting
+    fun performLogin() {
+        Timber.d("Login to start!")
+        val username = binding!!.loginUsername.text.toString()
+        val password = binding!!.loginPassword.text.toString()
+        val twoFactorCode = binding!!.loginTwoFactor.text.toString()
+
+        showLoggingProgressBar()
+        loginClient.doLogin(username,
+            password,
+            lastLoginResult,
+            twoFactorCode,
+            Locale.getDefault().language,
+            object : LoginCallback {
+                override fun success(loginResult: LoginResult) = runOnUiThread {
+                    Timber.d("Login Success")
+                    progressDialog!!.dismiss()
+                    onLoginSuccess(loginResult)
+                }
+
+                override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+                    Timber.d("Requesting 2FA prompt")
+                    progressDialog!!.dismiss()
+                    lastLoginResult = loginResult
+                    askUserForTwoFactorAuthWithKeyboard()
+                }
+
+                override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+                    Timber.d("Requesting email auth prompt")
+                    progressDialog!!.dismiss()
+                    lastLoginResult = loginResult
+                    askUserForTwoFactorAuthWithKeyboard()
+                }
+
+                override fun passwordResetPrompt(token: String?) = runOnUiThread {
+                    Timber.d("Showing password reset prompt")
+                    progressDialog!!.dismiss()
+                    showPasswordResetPrompt()
+                }
+
+                override fun error(caught: Throwable) = runOnUiThread {
+                    Timber.e(caught)
+                    progressDialog!!.dismiss()
+                    showMessageAndCancelDialog(caught.localizedMessage ?: "")
+                }
+            }
+        )
+    }
+
+    private fun showPasswordResetPrompt() =
+        showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
+
+    /**
+     * This function is called when user skips the login.
+     * It redirects the user to Explore Activity.
+     */
+    private fun performSkipLogin() {
+        applicationKvStore.putBoolean("login_skipped", true)
+        MainActivity.startYourself(this)
+        finish()
+    }
+
+    private fun showLoggingProgressBar() {
+        progressDialog = ProgressDialog(this).apply {
+            isIndeterminate = true
+            setTitle(getString(R.string.logging_in_title))
+            setMessage(getString(R.string.logging_in_message))
+            setCancelable(false)
+        }
+        progressDialog!!.show()
+    }
+
+    private fun onLoginSuccess(loginResult: LoginResult) {
+        compositeDisposable.clear()
+        sessionManager.setUserLoggedIn(true)
+        sessionManager.updateAccount(loginResult)
+        progressDialog!!.dismiss()
+        showSuccessAndDismissDialog()
+        startMainActivity()
+    }
+
+    override fun getMenuInflater(): MenuInflater =
+        delegate.menuInflater
+
+    @VisibleForTesting
+    fun askUserForTwoFactorAuth() {
+        if (binding == null) {
+            Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuth")
+            binding = ActivityLoginBinding.inflate(layoutInflater)
+            setContentView(binding!!.root)
+        }
+        progressDialog!!.dismiss()
+        if (binding != null) {
+            with(binding!!) {
+                twoFactorContainer.visibility = View.VISIBLE
+                twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+                loginTwoFactor.visibility = View.VISIBLE
+                loginTwoFactor.requestFocus()
+
+                loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+                    if (actionId == EditorInfo.IME_ACTION_DONE ||
+                        (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+                        performLogin()
+                        true
+                    } else {
+                        false
+                    }
+                }
+            }
+        } else {
+            Timber.e("Binding is null in askUserForTwoFactorAuth after reinitialization attempt")
+        }
+        val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+        imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
+        showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+    }
+
+    @VisibleForTesting
+    fun showMessageAndCancelDialog(@StringRes resId: Int) {
+        showMessage(resId, R.color.secondaryDarkColor)
+        progressDialog?.cancel()
+    }
+
+    @VisibleForTesting
+    fun showMessageAndCancelDialog(error: String) {
+        showMessage(error, R.color.secondaryDarkColor)
+        progressDialog?.cancel()
+    }
+
+    @VisibleForTesting
+    fun showSuccessAndDismissDialog() {
+        showMessage(R.string.login_success, R.color.primaryDarkColor)
+        progressDialog!!.dismiss()
+    }
+
+    @VisibleForTesting
+    fun startMainActivity() {
+        startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
+        finish()
+    }
+
+    private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
+        errorMessage.text = getString(resId)
+        errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+        errorMessageContainer.visibility = View.VISIBLE
+    }
+
+    private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
+        errorMessage.text = message
+        errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+        errorMessageContainer.visibility = View.VISIBLE
+    }
+
+    private fun onTextChanged(text: String) {
+        val enabled =
+            binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
+                    (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
+        binding!!.loginButton.isEnabled = enabled
+    }
+
+    companion object {
+        fun startYourself(context: Context) =
+            context.startActivity(Intent(context, LoginActivity::class.java))
+
+        const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
+        const val SAVE_ERROR_MESSAGE: String = "errorMessage"
+        const val SAVE_USERNAME: String = "username"
+        const val SAVE_PASSWORD: String = "password"
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java b/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
deleted file mode 100644
index 5b3ed08d7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-
-import org.wikipedia.dataclient.Service;
-import org.wikipedia.dataclient.mwapi.MwPostResponse;
-
-import java.util.Objects;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import io.reactivex.Observable;
-
-/**
- * Handler for logout
- */
-@Singleton
-public class LogoutClient {
-
-    private final Service service;
-
-    @Inject
-    public LogoutClient(@Named("commons-service") Service service) {
-        this.service = service;
-    }
-
-    /**
-     * Fetches the  CSRF token and uses that to post the logout api call
-     * @return
-     */
-    public Observable postLogout() {
-        return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
-                Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
deleted file mode 100644
index 0caa8e8e5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
+++ /dev/null
@@ -1,151 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.content.Context;
-import android.os.Build;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.wikipedia.login.LoginResult;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import io.reactivex.Completable;
-import io.reactivex.Observable;
-
-/**
- * Manage the current logged in user session.
- */
-@Singleton
-public class SessionManager {
-    private final Context context;
-    private Account currentAccount; // Unlike a savings account...  ;-)
-    private JsonKvStore defaultKvStore;
-    private static final String KEY_RAWUSERNAME = "rawusername";
-
-    @Inject
-    public SessionManager(Context context,
-                          @Named("default_preferences") JsonKvStore defaultKvStore) {
-        this.context = context;
-        this.currentAccount = null;
-        this.defaultKvStore = defaultKvStore;
-    }
-
-    private boolean createAccount(@NonNull String userName, @NonNull String password) {
-        Account account = getCurrentAccount();
-        if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) {
-            removeAccount();
-            account = new Account(userName, BuildConfig.ACCOUNT_TYPE);
-            return accountManager().addAccountExplicitly(account, password, null);
-        }
-        return true;
-    }
-
-    private void removeAccount() {
-        Account account = getCurrentAccount();
-        if (account != null) {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
-                accountManager().removeAccountExplicitly(account);
-            } else {
-                //noinspection deprecation
-                accountManager().removeAccount(account, null, null);
-            }
-        }
-    }
-
-    public void updateAccount(LoginResult result) {
-        boolean accountCreated = createAccount(result.getUserName(), result.getPassword());
-        if (accountCreated) {
-            setPassword(result.getPassword());
-        }
-    }
-
-    private void setPassword(@NonNull String password) {
-        Account account = getCurrentAccount();
-        if (account != null) {
-            accountManager().setPassword(account, password);
-        }
-    }
-
-    /**
-     * @return Account|null
-     */
-    @Nullable
-    public Account getCurrentAccount() {
-        if (currentAccount == null) {
-            AccountManager accountManager = AccountManager.get(context);
-            Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
-            if (allAccounts.length != 0) {
-                currentAccount = allAccounts[0];
-            }
-        }
-        return currentAccount;
-    }
-
-    public boolean doesAccountExist() {
-        return getCurrentAccount() != null;
-    }
-
-    @Nullable
-    public String getUserName() {
-        Account account = getCurrentAccount();
-        return account == null ? null : account.name;
-    }
-
-    @Nullable
-    private String getRawUserName() {
-        Account account = getCurrentAccount();
-        return account == null ? null : accountManager().getUserData(account, KEY_RAWUSERNAME);
-    }
-
-    public String getAuthorName(){
-        return getRawUserName() == null ? getUserName() : getRawUserName();
-    }
-
-
-    @Nullable
-    public String getPassword() {
-        Account account = getCurrentAccount();
-        return account == null ? null : accountManager().getPassword(account);
-    }
-
-    private AccountManager accountManager() {
-        return AccountManager.get(context);
-    }
-
-    public boolean isUserLoggedIn() {
-        return defaultKvStore.getBoolean("isUserLoggedIn", false);
-    }
-
-    void setUserLoggedIn(boolean isLoggedIn) {
-        defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
-    }
-
-    public void forceLogin(Context context) {
-        if (context != null) {
-            LoginActivity.startYourself(context);
-        }
-    }
-
-    /**
-     * 1. Clears existing accounts from account manager
-     * 2. Calls MediaWikiApi's logout function to clear cookies
-     * @return
-     */
-    public Completable logout() {
-        AccountManager accountManager = AccountManager.get(context);
-        Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
-        return Completable.fromObservable(Observable.fromArray(allAccounts)
-                .map(a -> accountManager.removeAccount(a, null, null).getResult()))
-                .doOnComplete(() -> {
-                    currentAccount = null;
-                });
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
new file mode 100644
index 000000000..c9eb7d2f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
@@ -0,0 +1,95 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import android.os.Build
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.reactivex.Completable
+import io.reactivex.Observable
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Manage the current logged in user session.
+ */
+@Singleton
+class SessionManager @Inject constructor(
+    private val context: Context,
+    @param:Named("default_preferences") private val defaultKvStore: JsonKvStore
+) {
+    private val accountManager: AccountManager get() = AccountManager.get(context)
+
+    private var _currentAccount: Account? = null // Unlike a savings account...  ;-)
+    val currentAccount: Account? get() {
+        if (_currentAccount == null) {
+            val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
+            if (allAccounts.isNotEmpty()) {
+                _currentAccount = allAccounts[0]
+            }
+        }
+        return _currentAccount
+    }
+
+    val userName: String?
+        get() = currentAccount?.name
+
+    var password: String?
+        get() = currentAccount?.let { accountManager.getPassword(it) }
+        private set(value) {
+            currentAccount?.let { accountManager.setPassword(it, value) }
+        }
+
+    val isUserLoggedIn: Boolean
+        get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
+
+    fun updateAccount(result: LoginResult) {
+        if (createAccount(result.userName!!, result.password!!)) {
+            password = result.password
+        }
+    }
+
+    fun doesAccountExist(): Boolean =
+        currentAccount != null
+
+    fun setUserLoggedIn(isLoggedIn: Boolean) =
+        defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
+
+    fun forceLogin(context: Context?) =
+        context?.let { LoginActivity.startYourself(it) }
+
+    fun getPreference(key: String): Boolean =
+        defaultKvStore.getBoolean(key)
+
+    fun logout(): Completable = Completable.fromObservable(
+        Observable.empty()
+            .doOnComplete {
+                removeAccount()
+                _currentAccount = null
+            }
+    )
+
+    private fun createAccount(userName: String, password: String): Boolean {
+        var account = currentAccount
+        if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
+            removeAccount()
+            account = Account(userName, ACCOUNT_TYPE)
+            return accountManager.addAccountExplicitly(account, password, null)
+        }
+        return true
+    }
+
+    private fun removeAccount() {
+        currentAccount?.let {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+                accountManager.removeAccountExplicitly(it)
+            } else {
+                accountManager.removeAccount(it, null, null)
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
deleted file mode 100644
index d6e4568fd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.os.Bundle;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.Toast;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.theme.BaseActivity;
-import timber.log.Timber;
-
-public class SignupActivity extends BaseActivity {
-
-    private WebView webView;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        Timber.d("Signup Activity started");
-
-        webView = new WebView(this);
-        setContentView(webView);
-
-        webView.setWebViewClient(new MyWebViewClient());
-        WebSettings webSettings = webView.getSettings();
-        /*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
-         trust Wikimedia's site... right?*/
-        webSettings.setJavaScriptEnabled(true);
-
-        webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
-    }
-
-    private class MyWebViewClient extends WebViewClient {
-        @Override
-        public boolean shouldOverrideUrlLoading(WebView view, String url) {
-            if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
-                //Signup success, so clear cookies, notify user, and load LoginActivity again
-                Timber.d("Overriding URL %s", url);
-
-                Toast toast = Toast.makeText(SignupActivity.this,
-                        R.string.account_created, Toast.LENGTH_LONG);
-                toast.show();
-                // terminate on task completion.
-                finish();
-                return true;
-            } else {
-                //If user clicks any other links in the webview
-                Timber.d("Not overriding URL, URL is: %s", url);
-                return false;
-            }
-        }
-    }
-
-    @Override
-    public void onBackPressed() {
-        if (webView.canGoBack()) {
-            webView.goBack();
-        } else {
-            super.onBackPressed();
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
new file mode 100644
index 000000000..22f557bcd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
@@ -0,0 +1,77 @@
+package fr.free.nrw.commons.auth
+
+import android.annotation.SuppressLint
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Bundle
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import timber.log.Timber
+
+class SignupActivity : BaseActivity() {
+    private var webView: WebView? = null
+
+    @SuppressLint("SetJavaScriptEnabled")
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        Timber.d("Signup Activity started")
+
+        webView = WebView(this)
+        applyEdgeToEdgeAllInsets(webView!!)
+        with(webView!!) {
+            setContentView(this)
+            webViewClient = MyWebViewClient()
+            // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
+            // trust Wikimedia's site... right?
+            settings.javaScriptEnabled = true
+            loadUrl(BuildConfig.SIGNUP_LANDING_URL)
+        }
+    }
+
+    override fun onBackPressed() {
+        if (webView!!.canGoBack()) {
+            webView!!.goBack()
+        } else {
+            super.onBackPressed()
+        }
+    }
+
+    /**
+     * Known bug in androidx.appcompat library version 1.1.0 being tracked here
+     * https://issuetracker.google.com/issues/141132133
+     * App tries to put light/dark theme to webview and crashes in the process
+     * This code tries to prevent applying the theme when sdk is between api 21 to 25
+     */
+    override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
+        if (Build.VERSION.SDK_INT <= 25 &&
+            (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
+        ) return
+        super.applyOverrideConfiguration(overrideConfiguration)
+    }
+
+    private inner class MyWebViewClient : WebViewClient() {
+        @Deprecated("Deprecated in Java")
+        override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
+            if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
+                //Signup success, so clear cookies, notify user, and load LoginActivity again
+                Timber.d("Overriding URL %s", url)
+
+                Toast.makeText(
+                    this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
+                ).show()
+
+                // terminate on task completion.
+                finish()
+                true
+            } else {
+                //If user clicks any other links in the webview
+                Timber.d("Not overriding URL, URL is: %s", url)
+                false
+            }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
deleted file mode 100644
index 643725604..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
+++ /dev/null
@@ -1,141 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.accounts.NetworkErrorException;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-
-import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
-
-/**
- * Handles WikiMedia commons account Authentication
- */
-public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
-    private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY};
-
-    @NonNull
-    private final Context context;
-
-    public WikiAccountAuthenticator(@NonNull Context context) {
-        super(context);
-        this.context = context;
-    }
-
-    /**
-     * Provides Bundle with edited Account Properties 
-     */
-    @Override
-    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
-        Bundle bundle = new Bundle();
-        bundle.putString("test", "editProperties");
-        return bundle;
-    }
-
-    @Override
-    public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
-                             @NonNull String accountType, @Nullable String authTokenType,
-                             @Nullable String[] requiredFeatures, @Nullable Bundle options)
-            throws NetworkErrorException {
-        // account type not supported returns bundle without loginActivity Intent, it just contains "test" key 
-        if (!supportedAccountType(accountType)) {
-            Bundle bundle = new Bundle();
-            bundle.putString("test", "addAccount");
-            return bundle;
-        }
-
-        return addAccount(response);
-    }
-
-    @Override
-    public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
-                                     @NonNull Account account, @Nullable Bundle options)
-            throws NetworkErrorException {
-        Bundle bundle = new Bundle();
-        bundle.putString("test", "confirmCredentials");
-        return bundle;
-    }
-
-    @Override
-    public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
-                               @NonNull Account account, @NonNull String authTokenType,
-                               @Nullable Bundle options)
-            throws NetworkErrorException {
-        Bundle bundle = new Bundle();
-        bundle.putString("test", "getAuthToken");
-        return bundle;
-    }
-
-    @Nullable
-    @Override
-    public String getAuthTokenLabel(@NonNull String authTokenType) {
-        return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
-    }
-
-    @Nullable
-    @Override
-    public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
-                                    @NonNull Account account, @Nullable String authTokenType,
-                                    @Nullable Bundle options)
-            throws NetworkErrorException {
-        Bundle bundle = new Bundle();
-        bundle.putString("test", "updateCredentials");
-        return bundle;
-    }
-
-    @Nullable
-    @Override
-    public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
-                              @NonNull Account account, @NonNull String[] features)
-            throws NetworkErrorException {
-        Bundle bundle = new Bundle();
-        bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
-        return bundle;
-    }
-
-    private boolean supportedAccountType(@Nullable String type) {
-        return BuildConfig.ACCOUNT_TYPE.equals(type);
-    }
-
-    /**
-     * Provides a bundle containing a Parcel 
-     * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
-     */
-    private Bundle addAccount(AccountAuthenticatorResponse response) {
-        Intent intent = new Intent(context, LoginActivity.class);
-        intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
-
-        Bundle bundle = new Bundle();
-        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
-
-        return bundle;
-    }
-
-    @Override
-    public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
-                                           Account account) throws NetworkErrorException {
-        Bundle result = super.getAccountRemovalAllowed(response, account);
-
-        if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
-                && !result.containsKey(AccountManager.KEY_INTENT)) {
-            boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
-
-            if (allowed) {
-                for (String auth : SYNC_AUTHORITIES) {
-                    ContentResolver.cancelSync(account, auth);
-                }
-            }
-        }
-
-        return result;
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
new file mode 100644
index 000000000..367989f14
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
@@ -0,0 +1,108 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.accounts.NetworkErrorException
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import fr.free.nrw.commons.BuildConfig
+
+private val SYNC_AUTHORITIES = arrayOf(
+    BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
+)
+
+/**
+ * Handles WikiMedia commons account Authentication
+ */
+class WikiAccountAuthenticator(
+    private val context: Context
+) : AbstractAccountAuthenticator(context) {
+    /**
+     * Provides Bundle with edited Account Properties
+     */
+    override fun editProperties(
+        response: AccountAuthenticatorResponse,
+        accountType: String
+    ) = bundleOf("test" to "editProperties")
+
+    // account type not supported returns bundle without loginActivity Intent, it just contains "test" key
+    @Throws(NetworkErrorException::class)
+    override fun addAccount(
+        response: AccountAuthenticatorResponse,
+        accountType: String,
+        authTokenType: String?,
+        requiredFeatures: Array?,
+        options: Bundle?
+    ) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
+        addAccount(response)
+    } else {
+        bundleOf("test" to "addAccount")
+    }
+
+    @Throws(NetworkErrorException::class)
+    override fun confirmCredentials(
+        response: AccountAuthenticatorResponse, account: Account, options: Bundle?
+    ) = bundleOf("test" to "confirmCredentials")
+
+    @Throws(NetworkErrorException::class)
+    override fun getAuthToken(
+        response: AccountAuthenticatorResponse,
+        account: Account,
+        authTokenType: String,
+        options: Bundle?
+    ) = bundleOf("test" to "getAuthToken")
+
+    override fun getAuthTokenLabel(authTokenType: String) =
+        if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
+
+    @Throws(NetworkErrorException::class)
+    override fun updateCredentials(
+        response: AccountAuthenticatorResponse,
+        account: Account,
+        authTokenType: String?,
+        options: Bundle?
+    ) = bundleOf("test" to "updateCredentials")
+
+    @Throws(NetworkErrorException::class)
+    override fun hasFeatures(
+        response: AccountAuthenticatorResponse,
+        account: Account, features: Array
+    ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
+
+    /**
+     * Provides a bundle containing a Parcel
+     * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
+     */
+    private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
+        val intent = Intent(context, LoginActivity::class.java)
+            .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+        return bundleOf(AccountManager.KEY_INTENT to intent)
+    }
+
+    @Throws(NetworkErrorException::class)
+    override fun getAccountRemovalAllowed(
+        response: AccountAuthenticatorResponse?,
+        account: Account?
+    ): Bundle {
+        val result = super.getAccountRemovalAllowed(response, account)
+
+        if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
+            && !result.containsKey(AccountManager.KEY_INTENT)
+        ) {
+            val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
+
+            if (allowed) {
+                for (auth in SYNC_AUTHORITIES) {
+                    ContentResolver.cancelSync(account, auth)
+                }
+            }
+        }
+
+        return result
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
deleted file mode 100644
index bb41f27aa..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.content.Intent;
-import android.os.IBinder;
-
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.di.CommonsDaggerService;
-
-/**
- * Handles the Auth service of the App, see AndroidManifests for details
- * (Uses Dagger 2 as injector)
- */
-public class WikiAccountAuthenticatorService extends CommonsDaggerService {
-
-    @Nullable
-    private AbstractAccountAuthenticator authenticator;
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        authenticator = new WikiAccountAuthenticator(this);
-    }
-
-    @Nullable
-    @Override
-    public IBinder onBind(Intent intent) {
-        return authenticator == null ? null : authenticator.getIBinder();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
new file mode 100644
index 000000000..852536a48
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
@@ -0,0 +1,22 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.content.Intent
+import android.os.IBinder
+import fr.free.nrw.commons.di.CommonsDaggerService
+
+/**
+ * Handles the Auth service of the App, see AndroidManifests for details
+ * (Uses Dagger 2 as injector)
+ */
+class WikiAccountAuthenticatorService : CommonsDaggerService() {
+    private var authenticator: AbstractAccountAuthenticator? = null
+
+    override fun onCreate() {
+        super.onCreate()
+        authenticator = WikiAccountAuthenticator(this)
+    }
+
+    override fun onBind(intent: Intent): IBinder? =
+        authenticator?.iBinder
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
new file mode 100644
index 000000000..6353e54ac
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
@@ -0,0 +1,217 @@
+package fr.free.nrw.commons.auth.csrf
+
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginFailedException
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.Response
+import timber.log.Timber
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors.newSingleThreadExecutor
+
+class CsrfTokenClient(
+    private val sessionManager: SessionManager,
+    private val csrfTokenInterface: CsrfTokenInterface,
+    private val loginClient: LoginClient,
+    private val logoutClient: LogoutClient,
+) {
+    private var retries = 0
+    private var csrfTokenCall: Call? = null
+
+    @Throws(Throwable::class)
+    fun getTokenBlocking(): String {
+        var token = ""
+        val userName = sessionManager.userName ?: ""
+        val password = sessionManager.password ?: ""
+
+        for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) {
+            try {
+                if (retry > 0) {
+                    // Log in explicitly
+                    loginClient.loginBlocking(userName, password)
+                }
+
+                // Get CSRFToken response off the main thread.
+                val response =
+                    newSingleThreadExecutor()
+                        .submit(
+                            Callable {
+                                csrfTokenInterface.getCsrfTokenCall().execute()
+                            },
+                        ).get()
+
+                if (response
+                        .body()
+                        ?.query()
+                        ?.csrfToken()
+                        .isNullOrEmpty()
+                ) {
+                    continue
+                }
+
+                token = response.body()!!.query()!!.csrfToken()!!
+                if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+                    throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+                }
+                break
+            } catch (e: LoginFailedException) {
+                throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+            } catch (t: Throwable) {
+                Timber.w(t)
+            }
+        }
+
+        if (token.isEmpty() || token == ANON_TOKEN) {
+            throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+        }
+        return token
+    }
+
+    @VisibleForTesting
+    fun request(
+        service: CsrfTokenInterface,
+        cb: Callback,
+    ): Call =
+        requestToken(
+            service,
+            object : Callback {
+                override fun success(token: String?) {
+                    if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+                        retryWithLogin(cb) {
+                            InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+                        }
+                    } else {
+                        cb.success(token)
+                    }
+                }
+
+                override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught }
+
+                override fun twoFactorPrompt() = cb.twoFactorPrompt()
+
+                override fun emailAuthPrompt() = cb.emailAuthPrompt()
+            },
+        )
+
+    @VisibleForTesting
+    fun requestToken(
+        service: CsrfTokenInterface,
+        cb: Callback,
+    ): Call {
+        val call = service.getCsrfTokenCall()
+        call.enqueue(
+            object : retrofit2.Callback {
+                override fun onResponse(
+                    call: Call,
+                    response: Response,
+                ) {
+                    if (call.isCanceled) {
+                        return
+                    }
+                    cb.success(response.body()!!.query()!!.csrfToken())
+                }
+
+                override fun onFailure(
+                    call: Call,
+                    t: Throwable,
+                ) {
+                    if (call.isCanceled) {
+                        return
+                    }
+                    cb.failure(t)
+                }
+            },
+        )
+        return call
+    }
+
+    private fun retryWithLogin(
+        callback: Callback,
+        caught: () -> Throwable?,
+    ) {
+        val userName = sessionManager.userName
+        val password = sessionManager.password
+        if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) {
+            retries++
+            logoutClient.logout()
+            login(userName, password, callback) {
+                Timber.i("retrying...")
+                cancel()
+                csrfTokenCall = request(csrfTokenInterface, callback)
+            }
+        } else {
+            callback.failure(caught())
+        }
+    }
+
+    private fun login(
+        username: String,
+        password: String,
+        callback: Callback,
+        retryCallback: () -> Unit,
+    ) = loginClient.request(
+        username,
+        password,
+        object : LoginCallback {
+            override fun success(loginResult: LoginResult) {
+                if (loginResult.pass) {
+                    sessionManager.updateAccount(loginResult)
+                    retryCallback()
+                } else {
+                    callback.failure(LoginFailedException(loginResult.message))
+                }
+            }
+
+            override fun twoFactorPrompt(
+                loginResult: LoginResult,
+                caught: Throwable,
+                token: String?,
+            ) = callback.twoFactorPrompt()
+
+            override fun emailAuthPrompt(
+                loginResult: LoginResult,
+                caught: Throwable,
+                token: String?,
+            ) = callback.emailAuthPrompt()
+
+            // Should not happen here, but call the callback just in case.
+            override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password."))
+
+            override fun error(caught: Throwable) = callback.failure(caught)
+        },
+    )
+
+    private fun cancel() {
+        loginClient.cancel()
+        if (csrfTokenCall != null) {
+            csrfTokenCall!!.cancel()
+            csrfTokenCall = null
+        }
+    }
+
+    interface Callback {
+        fun success(token: String?)
+
+        fun failure(caught: Throwable?)
+
+        fun twoFactorPrompt()
+
+        fun emailAuthPrompt()
+    }
+
+    companion object {
+        private const val ANON_TOKEN = "+\\"
+        private const val MAX_RETRIES = 1
+        private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2
+        const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure."
+        const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token."
+    }
+}
+
+class InvalidLoginTokenException(
+    message: String,
+) : Exception(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
new file mode 100644
index 000000000..949f2dddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
@@ -0,0 +1,13 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Headers
+
+interface CsrfTokenInterface {
+    @Headers("Cache-Control: no-cache")
+    @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf")
+    fun getCsrfTokenCall(): Call
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
new file mode 100644
index 000000000..84481c918
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
@@ -0,0 +1,12 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
+import javax.inject.Inject
+
+class LogoutClient
+    @Inject
+    constructor(
+        private val store: CommonsCookieStorage,
+    ) {
+        fun logout() = store.clear()
+    }
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
new file mode 100644
index 000000000..8aa3d17a0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.auth.login
+
+interface LoginCallback {
+    fun success(loginResult: LoginResult)
+
+    fun twoFactorPrompt(
+        loginResult: LoginResult,
+        caught: Throwable,
+        token: String?,
+    )
+
+    fun emailAuthPrompt(
+        loginResult: LoginResult,
+        caught: Throwable,
+        token: String?,
+    )
+
+    fun passwordResetPrompt(token: String?)
+
+    fun error(caught: Throwable)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
new file mode 100644
index 000000000..a653b8b55
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
@@ -0,0 +1,276 @@
+package fr.free.nrw.commons.auth.login
+
+import android.text.TextUtils
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import timber.log.Timber
+import java.io.IOException
+
+/**
+ * Responsible for making login related requests to the server.
+ */
+class LoginClient(
+    private val loginInterface: LoginInterface,
+) {
+    private var tokenCall: Call? = null
+    private var loginCall: Call? = null
+
+    /**
+     * userLanguage
+     * It holds the value of the user's device language code.
+     * For example, if user's device language is English it will hold En
+     * The value will be fetched when the user clicks Login Button in the LoginActivity
+     */
+    private var userLanguage = ""
+
+    private fun getLoginToken() = loginInterface.getLoginToken()
+
+    fun request(
+        userName: String,
+        password: String,
+        cb: LoginCallback,
+    ) {
+        cancel()
+
+        tokenCall = getLoginToken()
+        tokenCall!!.enqueue(
+            object : Callback {
+                override fun onResponse(
+                    call: Call,
+                    response: Response,
+                ) {
+                    login(
+                        userName,
+                        password,
+                        null,
+                        null,
+                        null,
+                        response.body()!!.query()!!.loginToken(),
+                        userLanguage,
+                        cb,
+                    )
+                }
+
+                override fun onFailure(
+                    call: Call,
+                    caught: Throwable,
+                ) {
+                    if (call.isCanceled) {
+                        return
+                    }
+                    cb.error(caught)
+                }
+            },
+        )
+    }
+
+    fun login(
+        userName: String,
+        password: String,
+        retypedPassword: String?,
+        twoFactorCode: String?,
+        emailAuthCode: String?,
+        loginToken: String?,
+        userLanguage: String,
+        cb: LoginCallback,
+    ) {
+        this.userLanguage = userLanguage
+
+        loginCall =
+            if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
+                loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+            } else {
+                loginInterface.postLogIn(
+                    userName,
+                    password,
+                    retypedPassword,
+                    twoFactorCode,
+                    emailAuthCode,
+                    loginToken,
+                    userLanguage,
+                    true,
+                )
+            }
+
+        loginCall!!.enqueue(
+            object : Callback {
+                override fun onResponse(
+                    call: Call,
+                    response: Response,
+                ) {
+                    val loginResult = response.body()?.toLoginResult(password)
+                    if (loginResult != null) {
+                        if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
+                            // The server could do some transformations on user names, e.g. on some
+                            // wikis is uppercases the first letter.
+                            getExtendedInfo(loginResult.userName, loginResult, cb)
+                        } else if ("UI" == loginResult.status) {
+                            when (loginResult) {
+                                is OAuthResult ->
+                                    cb.twoFactorPrompt(
+                                        loginResult,
+                                        LoginFailedException(loginResult.message),
+                                        loginToken,
+                                    )
+
+                                is EmailAuthResult ->
+                                    cb.emailAuthPrompt(
+                                        loginResult,
+                                        LoginFailedException(loginResult.message),
+                                        loginToken
+                                    )
+
+                                is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
+
+                                is LoginResult.Result ->
+                                    cb.error(
+                                        LoginFailedException(loginResult.message),
+                                    )
+                            }
+                        } else {
+                            cb.error(LoginFailedException(loginResult.message))
+                        }
+                    } else {
+                        cb.error(IOException("Login failed. Unexpected response."))
+                    }
+                }
+
+                override fun onFailure(
+                    call: Call,
+                    t: Throwable,
+                ) {
+                    if (call.isCanceled) {
+                        return
+                    }
+                    cb.error(t)
+                }
+            },
+        )
+    }
+
+    fun doLogin(
+        username: String,
+        password: String,
+        lastLoginResult: LoginResult?,
+        twoFactorCode: String,
+        userLanguage: String,
+        loginCallback: LoginCallback,
+    ) {
+        getLoginToken().enqueue(
+            object : Callback {
+                override fun onResponse(
+                    call: Call,
+                    response: Response,
+                ) = if (response.isSuccessful) {
+                    val loginToken = response.body()?.query()?.loginToken()
+                    loginToken?.let {
+                        login(username, password, null,
+                            if (lastLoginResult is OAuthResult) twoFactorCode else null,
+                            if (lastLoginResult is EmailAuthResult) twoFactorCode else null,
+                            it, userLanguage, loginCallback)
+                    } ?: run {
+                        loginCallback.error(IOException("Failed to retrieve login token"))
+                    }
+                } else {
+                    loginCallback.error(IOException("Failed to retrieve login token"))
+                }
+
+                override fun onFailure(
+                    call: Call,
+                    t: Throwable,
+                ) {
+                    loginCallback.error(t)
+                }
+            },
+        )
+    }
+
+    @Throws(Throwable::class)
+    fun loginBlocking(
+        userName: String,
+        password: String,
+        twoFactorCode: String? = null,
+        emailAuthCode: String? = null
+    ) {
+        val tokenResponse = getLoginToken().execute()
+        if (tokenResponse
+                .body()
+                ?.query()
+                ?.loginToken()
+                .isNullOrEmpty()
+        ) {
+            throw IOException("Unexpected response when getting login token.")
+        }
+
+        val loginToken = tokenResponse.body()?.query()?.loginToken()
+        val tempLoginCall =
+            if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) {
+                loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+            } else {
+                loginInterface.postLogIn(
+                    userName,
+                    password,
+                    null,
+                    twoFactorCode,
+                    emailAuthCode,
+                    loginToken,
+                    userLanguage,
+                    true,
+                )
+            }
+
+        val response = tempLoginCall.execute()
+        val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.")
+        val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.")
+
+        if ("UI" == loginResult.status) {
+            if (loginResult is OAuthResult || loginResult is EmailAuthResult) {
+                // TODO: Find a better way to boil up the warning about 2FA
+                throw LoginFailedException(loginResult.message)
+            }
+            throw LoginFailedException(loginResult.message)
+        }
+
+        if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
+            throw LoginFailedException(loginResult.message)
+        }
+    }
+
+    private fun getExtendedInfo(
+        userName: String,
+        loginResult: LoginResult,
+        cb: LoginCallback,
+    ) = loginInterface
+        .getUserInfo(userName)
+        .subscribeOn(Schedulers.io())
+        .observeOn(AndroidSchedulers.mainThread())
+        .subscribe({ response: MwQueryResponse? ->
+            loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
+            loginResult.groups =
+                response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
+            cb.success(loginResult)
+        }, { caught: Throwable ->
+            Timber.e(caught, "Login succeeded but getting group information failed. ")
+            cb.error(caught)
+        })
+
+    fun cancel() {
+        tokenCall?.let {
+            it.cancel()
+            tokenCall = null
+        }
+
+        loginCall?.let {
+            it.cancel()
+            loginCall = null
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
new file mode 100644
index 000000000..fb5ad14c6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.auth.login
+
+class LoginFailedException(
+    message: String?,
+) : Throwable(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
new file mode 100644
index 000000000..39cbf7c9f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
@@ -0,0 +1,48 @@
+package fr.free.nrw.commons.auth.login
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Observable
+import retrofit2.Call
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+interface LoginInterface {
+    @Headers("Cache-Control: no-cache")
+    @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login")
+    fun getLoginToken(): Call
+
+    @Headers("Cache-Control: no-cache")
+    @FormUrlEncoded
+    @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+    fun postLogIn(
+        @Field("username") user: String?,
+        @Field("password") pass: String?,
+        @Field("logintoken") token: String?,
+        @Field("uselang") userLanguage: String?,
+        @Field("loginreturnurl") url: String?,
+    ): Call
+
+    @Headers("Cache-Control: no-cache")
+    @FormUrlEncoded
+    @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+    fun postLogIn(
+        @Field("username") user: String?,
+        @Field("password") pass: String?,
+        @Field("retype") retypedPass: String?,
+        @Field("OATHToken") twoFactorCode: String?,
+        @Field("token") emailAuthToken: String?,
+        @Field("logintoken") loginToken: String?,
+        @Field("uselang") userLanguage: String?,
+        @Field("logincontinue") loginContinue: Boolean,
+    ): Call
+
+    @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
+    fun getUserInfo(
+        @Query("ususers") userName: String,
+    ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
new file mode 100644
index 000000000..0fb035eea
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
@@ -0,0 +1,64 @@
+package fr.free.nrw.commons.auth.login
+
+import com.google.gson.annotations.SerializedName
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.auth.login.LoginResult.Result
+import fr.free.nrw.commons.wikidata.mwapi.MwServiceError
+
+class LoginResponse {
+    @SerializedName("error")
+    val error: MwServiceError? = null
+
+    @SerializedName("clientlogin")
+    private val clientLogin: ClientLogin? = null
+
+    fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
+}
+
+internal class ClientLogin {
+    private val status: String? = null
+    private val requests: List? = null
+    private val message: String? = null
+
+    @SerializedName("username")
+    private val userName: String? = null
+
+    fun toLoginResult(password: String): LoginResult {
+        var userMessage = message
+        if ("UI" == status) {
+            requests?.forEach { request ->
+                request.id()?.let {
+                    if (it.endsWith("TOTPAuthenticationRequest")) {
+                        return OAuthResult(status, userName, password, message)
+                    } else if (it.endsWith("EmailAuthAuthenticationRequest")) {
+                        return EmailAuthResult(status, userName, password, message)
+                    } else if (it.endsWith("PasswordAuthenticationRequest")) {
+                        return ResetPasswordResult(status, userName, password, message)
+                    }
+                }
+            }
+        } else if ("PASS" != status && "FAIL" != status) {
+            // TODO: String resource -- Looks like needed for others in this class too
+            userMessage = "An unknown error occurred."
+        }
+        return Result(status ?: "", userName, password, userMessage)
+    }
+}
+
+internal class Request {
+    private val id: String? = null
+    private val required: String? = null
+    private val provider: String? = null
+    private val account: String? = null
+    internal val fields: Map? = null
+
+    fun id(): String? = id
+}
+
+internal class RequestField {
+    private val type: String? = null
+    private val label: String? = null
+    internal val help: String? = null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
new file mode 100644
index 000000000..99abaeeec
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.auth.login
+
+sealed class LoginResult(
+    val status: String,
+    val userName: String?,
+    val password: String?,
+    val message: String?,
+) {
+    var userId = 0
+    var groups = emptySet()
+    val pass: Boolean get() = "PASS" == status
+
+    class Result(
+        status: String,
+        userName: String?,
+        password: String?,
+        message: String?,
+    ) : LoginResult(status, userName, password, message)
+
+    class OAuthResult(
+        status: String,
+        userName: String?,
+        password: String?,
+        message: String?,
+    ) : LoginResult(status, userName, password, message)
+
+    class EmailAuthResult(
+        status: String,
+        userName: String?,
+        password: String?,
+        message: String?,
+    ) : LoginResult(status, userName, password, message)
+
+    class ResetPasswordResult(
+        status: String,
+        userName: String?,
+        password: String?,
+        message: String?,
+    ) : LoginResult(status, userName, password, message)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt
deleted file mode 100644
index 260847c3f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package fr.free.nrw.commons.bookmarks
-
-import android.net.Uri
-
-class Bookmark(mediaName: String?, mediaCreator: String?,
-               /**
-                * Modifies the content URI - marking this bookmark as already saved in the database
-                * @param contentUri the content URI
-                */
-               var contentUri: Uri?) {
-    /**
-     * Gets the content URI for this bookmark
-     * @return content URI
-     */
-    /**
-     * Gets the media name
-     * @return the media name
-     */
-    val mediaName: String = mediaName ?: ""
-    /**
-     * Gets media creator
-     * @return creator name
-     */
-    val mediaCreator: String = mediaCreator ?: ""
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt
new file mode 100644
index 000000000..51f15b23c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt
@@ -0,0 +1,98 @@
+package fr.free.nrw.commons.bookmarks
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import fr.free.nrw.commons.contributions.ContributionController
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.FragmentBookmarksBinding
+import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.theme.BaseActivity
+import javax.inject.Inject
+import javax.inject.Named
+
+class BookmarkFragment : CommonsDaggerSupportFragment() {
+    private var adapter: BookmarksPagerAdapter? = null
+
+    @JvmField
+    var binding: FragmentBookmarksBinding? = null
+
+    @JvmField
+    @Inject
+    var controller: ContributionController? = null
+
+    /**
+     * To check if the user is loggedIn or not.
+     */
+    @JvmField
+    @Inject
+    @Named("default_preferences")
+    var applicationKvStore: JsonKvStore? = null
+
+    fun setScroll(canScroll: Boolean) {
+        binding?.let {
+            it.viewPagerBookmarks.canScroll = canScroll
+        }
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        super.onCreateView(inflater, container, savedInstanceState)
+        binding = FragmentBookmarksBinding.inflate(inflater, container, false)
+
+        // Activity can call methods in the fragment by acquiring a
+        // reference to the Fragment from FragmentManager, using findFragmentById()
+        val supportFragmentManager = childFragmentManager
+
+        adapter = BookmarksPagerAdapter(
+            supportFragmentManager, requireContext(),
+            applicationKvStore!!.getBoolean("login_skipped")
+        )
+        binding!!.viewPagerBookmarks.adapter = adapter
+        binding!!.tabLayout.setupWithViewPager(binding!!.viewPagerBookmarks)
+
+        (requireActivity() as MainActivity).showTabs()
+        (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+
+        setupTabLayout()
+        return binding!!.root
+    }
+
+    /**
+     * This method sets up the tab layout. If the adapter has only one element it sets the
+     * visibility of tabLayout to gone.
+     */
+    fun setupTabLayout() {
+        binding!!.tabLayout.visibility = View.VISIBLE
+        if (adapter!!.count == 1) {
+            binding!!.tabLayout.visibility = View.GONE
+        }
+    }
+
+
+    fun onBackPressed() {
+        if (((adapter!!.getItem(binding!!.tabLayout.selectedTabPosition)) as BookmarkListRootFragment).backPressed()) {
+            // The event is handled internally by the adapter , no further action required.
+            return
+        }
+
+        // Event is not handled by the adapter ( performed back action ) change action bar.
+        (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        binding = null
+    }
+
+    companion object {
+        fun newInstance(): BookmarkFragment = BookmarkFragment().apply {
+            retainInstance = true
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt
new file mode 100644
index 000000000..a9ed33abc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt
@@ -0,0 +1,226 @@
+package fr.free.nrw.commons.bookmarks
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.AdapterView.OnItemClickListener
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment
+import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment
+import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment
+import fr.free.nrw.commons.category.CategoryImagesCallback
+import fr.free.nrw.commons.category.GridViewAdapter
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding
+import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
+import fr.free.nrw.commons.media.MediaDetailPagerFragment
+import fr.free.nrw.commons.media.MediaDetailPagerFragment.Companion.newInstance
+import fr.free.nrw.commons.media.MediaDetailProvider
+import fr.free.nrw.commons.navtab.NavTab
+import timber.log.Timber
+
+class BookmarkListRootFragment : CommonsDaggerSupportFragment,
+    FragmentManager.OnBackStackChangedListener, MediaDetailProvider, OnItemClickListener,
+    CategoryImagesCallback {
+    private var mediaDetails: MediaDetailPagerFragment? = null
+    private val bookmarkLocationsFragment: BookmarkLocationsFragment? = null
+    var listFragment: Fragment? = null
+    private var bookmarksPagerAdapter: BookmarksPagerAdapter? = null
+
+    var binding: FragmentFeaturedRootBinding? = null
+
+    constructor()
+
+    constructor(bundle: Bundle, bookmarksPagerAdapter: BookmarksPagerAdapter) {
+        val title = bundle.getString("categoryName")
+        val order = bundle.getInt("order")
+        val orderItem = bundle.getInt("orderItem")
+
+        when (order) {
+            0 -> listFragment = BookmarkPicturesFragment()
+            1 -> listFragment = BookmarkLocationsFragment()
+            3 -> listFragment = BookmarkCategoriesFragment()
+        }
+        if (orderItem == 2) {
+            listFragment = BookmarkItemsFragment()
+        }
+
+        val featuredArguments = Bundle()
+        featuredArguments.putString("categoryName", title)
+        listFragment!!.setArguments(featuredArguments)
+        this.bookmarksPagerAdapter = bookmarksPagerAdapter
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        super.onCreate(savedInstanceState)
+        binding = FragmentFeaturedRootBinding.inflate(inflater, container, false)
+        return binding!!.getRoot()
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        if (savedInstanceState == null) {
+            setFragment(listFragment!!, mediaDetails)
+        }
+    }
+
+    fun setFragment(fragment: Fragment, otherFragment: Fragment?) {
+        if (fragment.isAdded() && otherFragment != null) {
+            getChildFragmentManager()
+                .beginTransaction()
+                .hide(otherFragment)
+                .show(fragment)
+                .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG")
+                .commit()
+            getChildFragmentManager().executePendingTransactions()
+        } else if (fragment.isAdded() && otherFragment == null) {
+            getChildFragmentManager()
+                .beginTransaction()
+                .show(fragment)
+                .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG")
+                .commit()
+            getChildFragmentManager().executePendingTransactions()
+        } else if (!fragment.isAdded() && otherFragment != null) {
+            getChildFragmentManager()
+                .beginTransaction()
+                .hide(otherFragment)
+                .add(R.id.explore_container, fragment)
+                .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG")
+                .commit()
+            getChildFragmentManager().executePendingTransactions()
+        } else if (!fragment.isAdded()) {
+            getChildFragmentManager()
+                .beginTransaction()
+                .replace(R.id.explore_container, fragment)
+                .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG")
+                .commit()
+            getChildFragmentManager().executePendingTransactions()
+        }
+    }
+
+    fun removeFragment(fragment: Fragment) {
+        getChildFragmentManager()
+            .beginTransaction()
+            .remove(fragment)
+            .commit()
+        getChildFragmentManager().executePendingTransactions()
+    }
+
+    override fun onMediaClicked(position: Int) {
+        Timber.d("on media clicked")
+        /*container.setVisibility(View.VISIBLE);
+    ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE);
+    mediaDetails = new MediaDetailPagerFragment(false, true, position);
+    setFragment(mediaDetails, bookmarkPicturesFragment);*/
+    }
+
+    /**
+     * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
+     *
+     * @param i It is the index of which media object is to be returned which is same as current
+     * index of viewPager.
+     * @return Media Object
+     */
+    override fun getMediaAtPosition(i: Int): Media? =
+        bookmarksPagerAdapter!!.mediaAdapter?.getItem(i) as Media?
+
+    /**
+     * This method is called on from getCount of MediaDetailPagerFragment The viewpager will contain
+     * same number of media items as that of media elements in adapter.
+     *
+     * @return Total Media count in the adapter
+     */
+    override fun getTotalMediaCount(): Int =
+        bookmarksPagerAdapter!!.mediaAdapter?.count ?: 0
+
+    override fun getContributionStateAt(position: Int): Int? {
+        return null
+    }
+
+    /**
+     * Reload media detail fragment once media is nominated
+     *
+     * @param index item position that has been nominated
+     */
+    override fun refreshNominatedMedia(index: Int) {
+        if (mediaDetails != null && !listFragment!!.isVisible()) {
+            removeFragment(mediaDetails!!)
+            mediaDetails = newInstance(false, true)
+            (parentFragment as BookmarkFragment).setScroll(false)
+            setFragment(mediaDetails!!, listFragment)
+            mediaDetails!!.showImage(index)
+        }
+    }
+
+    /**
+     * This method is called on success of API call for featured images or mobile uploads. The
+     * viewpager will notified that number of items have changed.
+     */
+    override fun viewPagerNotifyDataSetChanged() {
+        if (mediaDetails != null) {
+            mediaDetails!!.notifyDataSetChanged()
+        }
+    }
+
+    fun backPressed(): Boolean {
+        //check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException
+        if (mediaDetails != null) {
+            if (mediaDetails!!.isVisible()) {
+                // todo add get list fragment
+                (parentFragment as BookmarkFragment).setupTabLayout()
+                val removed: ArrayList = mediaDetails!!.removedItems
+                removeFragment(mediaDetails!!)
+                (parentFragment as BookmarkFragment).setScroll(true)
+                setFragment(listFragment!!, mediaDetails)
+                (requireActivity() as MainActivity).showTabs()
+                if (listFragment is BookmarkPicturesFragment) {
+                    val adapter = ((listFragment as BookmarkPicturesFragment)
+                        .getAdapter() as GridViewAdapter?)
+                    val i: MutableIterator<*> = removed.iterator()
+                    while (i.hasNext()) {
+                        adapter!!.remove(adapter.getItem(i.next() as Int))
+                    }
+                    mediaDetails!!.clearRemoved()
+                }
+            } else {
+                moveToContributionsFragment()
+            }
+        } else {
+            moveToContributionsFragment()
+        }
+        // notify mediaDetails did not handled the backPressed further actions required.
+        return false
+    }
+
+    fun moveToContributionsFragment() {
+        (requireActivity() as MainActivity).setSelectedItemId(NavTab.CONTRIBUTIONS.code())
+        (requireActivity() as MainActivity).showTabs()
+    }
+
+    override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+        Timber.d("on media clicked")
+        binding!!.exploreContainer.visibility = View.VISIBLE
+        (parentFragment as BookmarkFragment).binding!!.tabLayout.setVisibility(View.GONE)
+        mediaDetails = newInstance(false, true)
+        (parentFragment as BookmarkFragment).setScroll(false)
+        setFragment(mediaDetails!!, listFragment)
+        mediaDetails!!.showImage(position)
+    }
+
+    override fun onBackStackChanged() = Unit
+
+    override fun onDestroy() {
+        super.onDestroy()
+        binding = null
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java
deleted file mode 100644
index 71690c5e2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package fr.free.nrw.commons.bookmarks;
-
-import androidx.fragment.app.Fragment;
-
-/**
- * Data class for handling a bookmark fragment and it title
- */
-public class BookmarkPages {
-    private Fragment page;
-    private String title;
-
-    BookmarkPages(Fragment fragment, String title) {
-        this.title = title;
-        this.page = fragment;
-    }
-
-    /**
-     * Return the fragment
-     * @return fragment object
-     */
-    public Fragment getPage() {
-        return page;
-    }
-
-    /**
-     * Return the fragment title
-     * @return title
-     */
-    public String getTitle() {
-        return title;
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt
new file mode 100644
index 000000000..e0ade52fe
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt
@@ -0,0 +1,8 @@
+package fr.free.nrw.commons.bookmarks
+
+import androidx.fragment.app.Fragment
+
+data class BookmarkPages (
+    val page: Fragment? = null,
+    val title: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksActivity.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksActivity.java
deleted file mode 100644
index 2dfeb8b86..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksActivity.java
+++ /dev/null
@@ -1,145 +0,0 @@
-package fr.free.nrw.commons.bookmarks;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-import android.widget.AdapterView;
-
-import androidx.fragment.app.FragmentManager;
-import androidx.viewpager.widget.ViewPager;
-
-import com.google.android.material.tabs.TabLayout;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.category.CategoryImagesCallback;
-import fr.free.nrw.commons.contributions.ContributionController;
-import fr.free.nrw.commons.media.MediaDetailPagerFragment;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-
-public class BookmarksActivity extends NavigationBaseActivity
-        implements FragmentManager.OnBackStackChangedListener,
-        MediaDetailPagerFragment.MediaDetailProvider,
-        AdapterView.OnItemClickListener, CategoryImagesCallback {
-
-    private FragmentManager supportFragmentManager;
-    private BookmarksPagerAdapter adapter;
-    private MediaDetailPagerFragment mediaDetails;
-    @BindView(R.id.viewPagerBookmarks)
-    ViewPager viewPager;
-    @BindView(R.id.tab_layout)
-    TabLayout tabLayout;
-
-    @Inject
-    ContributionController controller;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_bookmarks);
-        ButterKnife.bind(this);
-
-        // Activity can call methods in the fragment by acquiring a
-        // reference to the Fragment from FragmentManager, using findFragmentById()
-        supportFragmentManager = getSupportFragmentManager();
-        supportFragmentManager.addOnBackStackChangedListener(this);
-        initDrawer();
-
-        adapter = new BookmarksPagerAdapter(supportFragmentManager, this);
-        viewPager.setAdapter(adapter);
-        tabLayout.setupWithViewPager(viewPager);
-    }
-
-    /**
-     * Consumers should be simply using this method to use this activity.
-     * @param context A Context of the application package implementing this class.
-     */
-    public static void startYourself(Context context) {
-        Intent intent = new Intent(context, BookmarksActivity.class);
-        intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        context.startActivity(intent);
-    }
-
-    @Override
-    public void onBackStackChanged() {
-        if (supportFragmentManager.getBackStackEntryCount() == 0) {
-            // The activity has the focus
-            adapter.requestPictureListUpdate();
-            initDrawer();
-        }
-    }
-
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-        controller.handleActivityResult(this, requestCode, resultCode, data);
-    }
-
-    /**
-     * This method is called onClick of media inside category details (CategoryImageListFragment).
-     */
-    @Override
-    public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
-        if (mediaDetails == null || !mediaDetails.isVisible()) {
-            mediaDetails = new MediaDetailPagerFragment(false, true);
-            supportFragmentManager
-                    .beginTransaction()
-                    .hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount()))
-                    .add(R.id.fragmentContainer, mediaDetails)
-                    .addToBackStack(null)
-                    .commit();
-            supportFragmentManager.executePendingTransactions();
-        }
-        mediaDetails.showImage(i);
-        forceInitBackButton();
-    }
-
-    /**
-     * This method is called on success of API call for featured Images.
-     * The viewpager will notified that number of items have changed.
-     */
-    @Override
-    public void viewPagerNotifyDataSetChanged() {
-        if (mediaDetails!=null){
-            mediaDetails.notifyDataSetChanged();
-        }
-    }
-
-
-    /**
-     * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
-     * @param i It is the index of which media object is to be returned which is same as
-     *          current index of viewPager.
-     * @return Media Object
-     */
-    @Override
-    public Media getMediaAtPosition(int i) {
-        if (adapter.getMediaAdapter() == null) {
-            // not yet ready to return data
-            return null;
-        } else {
-            return (Media) adapter.getMediaAdapter().getItem(i);
-        }
-    }
-
-    /**
-     * This method is called on from getCount of MediaDetailPagerFragment
-     * The viewpager will contain same number of media items as that of media elements in adapter.
-     * @return Total Media count in the adapter
-     */
-    @Override
-    public int getTotalMediaCount() {
-        if (adapter.getMediaAdapter() == null) {
-            return 0;
-        }
-        return adapter.getMediaAdapter().getCount();
-    }
-
-    @Override
-    public void requestMoreImages() { }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java
deleted file mode 100644
index bc2dbb432..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package fr.free.nrw.commons.bookmarks;
-
-import android.content.Context;
-import android.widget.ListAdapter;
-
-import androidx.annotation.Nullable;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentPagerAdapter;
-
-import java.util.ArrayList;
-
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment;
-import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment;
-
-public class BookmarksPagerAdapter extends FragmentPagerAdapter {
-
-    private ArrayList pages;
-
-    BookmarksPagerAdapter(FragmentManager fm, Context context) {
-        super(fm);
-        pages = new ArrayList<>();
-        pages.add(new BookmarkPages(
-                BookmarkPicturesFragment.newInstance(),
-                context.getString(R.string.title_page_bookmarks_pictures)
-        ));
-        pages.add(new BookmarkPages(
-                BookmarkLocationsFragment.newInstance(),
-                context.getString(R.string.title_page_bookmarks_locations)
-        ));
-        notifyDataSetChanged();
-    }
-
-    @Override
-    public Fragment getItem(int position) {
-        return pages.get(position).getPage();
-    }
-
-    @Override
-    public int getCount() {
-        return pages.size();
-    }
-
-    @Nullable
-    @Override
-    public CharSequence getPageTitle(int position) {
-        return pages.get(position).getTitle();
-    }
-
-    /**
-     * Return the Adapter used to display the picture gridview
-     * @return adapter
-     */
-    public ListAdapter getMediaAdapter() {
-        BookmarkPicturesFragment fragment = (BookmarkPicturesFragment)(pages.get(0).getPage());
-        return fragment.getAdapter();
-    }
-
-    /**
-     * Update the pictures list for the bookmark fragment
-     */
-    public void requestPictureListUpdate() {
-        BookmarkPicturesFragment fragment = (BookmarkPicturesFragment)(pages.get(0).getPage());
-        fragment.onResume();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt
new file mode 100644
index 000000000..a7cbf0e68
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt
@@ -0,0 +1,82 @@
+package fr.free.nrw.commons.bookmarks
+
+import android.content.Context
+import android.widget.ListAdapter
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment
+
+class BookmarksPagerAdapter internal constructor(
+    fm: FragmentManager, context: Context, onlyPictures: Boolean
+) : FragmentPagerAdapter(fm) {
+    private val pages = mutableListOf()
+
+    /**
+     * Default Constructor
+     * @param fm
+     * @param context
+     * @param onlyPictures is true if the fragment requires only BookmarkPictureFragment
+     * (i.e. when no user is logged in).
+     */
+    init {
+        pages.add(
+            BookmarkPages(
+                BookmarkListRootFragment(
+                    bundleOf(
+                        "categoryName" to context.getString(R.string.title_page_bookmarks_pictures),
+                        "order" to 0
+                    ), this
+                ), context.getString(R.string.title_page_bookmarks_pictures)
+            )
+        )
+        if (!onlyPictures) {
+            // if onlyPictures is false we also add the location fragment.
+            val locationBundle = bundleOf(
+                "categoryName" to context.getString(R.string.title_page_bookmarks_locations),
+                "order" to 1
+            )
+
+            pages.add(
+                BookmarkPages(
+                    BookmarkListRootFragment(locationBundle, this),
+                    context.getString(R.string.title_page_bookmarks_locations)
+                )
+            )
+
+            locationBundle.putInt("orderItem", 2)
+            pages.add(
+                BookmarkPages(
+                    BookmarkListRootFragment(locationBundle, this),
+                    context.getString(R.string.title_page_bookmarks_items)
+                )
+            )
+        }
+        pages.add(
+            BookmarkPages(
+                BookmarkListRootFragment(
+                    bundleOf(
+                        "categoryName" to context.getString(R.string.title_page_bookmarks_categories),
+                        "order" to 3
+                    ), this),
+                context.getString(R.string.title_page_bookmarks_categories)
+            )
+        )
+        notifyDataSetChanged()
+    }
+
+    override fun getItem(position: Int): Fragment = pages[position].page!!
+
+    override fun getCount(): Int = pages.size
+
+    override fun getPageTitle(position: Int): CharSequence? = pages[position].title
+
+    /**
+     * Return the Adapter used to display the picture gridview
+     * @return adapter
+     */
+    val mediaAdapter: ListAdapter?
+        get() = (((pages[0].page as BookmarkListRootFragment).listFragment) as BookmarkPicturesFragment).getAdapter()
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt
new file mode 100644
index 000000000..71a2d1ec9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt
@@ -0,0 +1,52 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Bookmark categories dao
+ *
+ * @constructor Create empty Bookmark categories dao
+ */
+@Dao
+interface BookmarkCategoriesDao {
+
+    /**
+     * Insert or Delete category bookmark into DB
+     *
+     * @param bookmarksCategoryModal
+     */
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal)
+
+
+    /**
+     * Delete category bookmark from DB
+     *
+     * @param bookmarksCategoryModal
+     */
+    @Delete
+    suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal)
+
+    /**
+     * Checks if given category exist in DB
+     *
+     * @param categoryName
+     * @return
+     */
+    @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)")
+    suspend fun doesExist(categoryName: String): Boolean
+
+    /**
+     * Get all categories
+     *
+     * @return
+     */
+    @Query("SELECT * FROM bookmarks_categories")
+    fun getAllCategories(): Flow>
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt
new file mode 100644
index 000000000..ef5bc613d
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt
@@ -0,0 +1,143 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dagger.android.support.DaggerFragment
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.category.CategoryDetailsActivity
+import javax.inject.Inject
+
+/**
+ * Tab fragment to show list of bookmarked Categories
+ */
+class BookmarkCategoriesFragment : DaggerFragment() {
+
+    @Inject
+    lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao
+
+    override fun onCreateView(
+        inflater: LayoutInflater, container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        return ComposeView(requireContext()).apply {
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                MaterialTheme(
+                    colorScheme = if (isSystemInDarkTheme()) darkColorScheme(
+                        primary = colorResource(R.color.primaryDarkColor),
+                        surface = colorResource(R.color.main_background_dark),
+                        background = colorResource(R.color.main_background_dark)
+                    ) else lightColorScheme(
+                        primary = colorResource(R.color.primaryColor),
+                        surface = colorResource(R.color.main_background_light),
+                        background = colorResource(R.color.main_background_light)
+                    )
+                ) {
+                    val listOfBookmarks by bookmarkCategoriesDao.getAllCategories()
+                        .collectAsStateWithLifecycle(initialValue = emptyList())
+                    Surface(modifier = Modifier.fillMaxSize()) {
+                        Box(contentAlignment = Alignment.Center) {
+                            if (listOfBookmarks.isEmpty()) {
+                                Text(
+                                    text = stringResource(R.string.bookmark_empty),
+                                    style = MaterialTheme.typography.bodyMedium,
+                                    color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF)
+                                    else Color(
+                                        0x8A000000
+                                    )
+                                )
+                            } else {
+                                LazyColumn(modifier = Modifier.fillMaxSize()) {
+                                    items(items = listOfBookmarks) { bookmarkItem ->
+                                        CategoryItem(
+                                            categoryName = bookmarkItem.categoryName,
+                                            onClick = {
+                                                val categoryDetailsIntent = Intent(
+                                                    requireContext(),
+                                                    CategoryDetailsActivity::class.java
+                                                ).putExtra("categoryName", it)
+                                                startActivity(categoryDetailsIntent)
+                                            }
+                                        )
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+
+    @Composable
+    fun CategoryItem(
+        modifier: Modifier = Modifier,
+        onClick: (String) -> Unit,
+        categoryName: String
+    ) {
+        Row(modifier = modifier.clickable {
+            onClick(categoryName)
+        }) {
+            ListItem(
+                leadingContent = {
+                    Image(
+                        modifier = Modifier.size(48.dp),
+                        painter = painterResource(R.drawable.commons),
+                        contentDescription = null
+                    )
+                },
+                headlineContent = {
+                    Text(
+                        text = categoryName,
+                        maxLines = 2,
+                        color = if (isSystemInDarkTheme()) Color.White else Color.Black,
+                        style = MaterialTheme.typography.bodyMedium,
+                        fontWeight = FontWeight.SemiBold
+                    )
+                }
+            )
+        }
+    }
+
+    @Preview
+    @Composable
+    private fun CategoryItemPreview() {
+        CategoryItem(
+            onClick = {},
+            categoryName = "Test Category"
+        )
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt
new file mode 100644
index 000000000..ab679611f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt
@@ -0,0 +1,15 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Data class representing bookmarked category in DB
+ *
+ * @property categoryName
+ * @constructor Create empty Bookmarks category modal
+ */
+@Entity(tableName = "bookmarks_categories")
+data class BookmarksCategoryModal(
+    @PrimaryKey val categoryName: String
+)
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt
new file mode 100644
index 000000000..4233d9508
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt
@@ -0,0 +1,61 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import com.facebook.drawee.view.SimpleDraweeView
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+
+/**
+ * Helps to inflate Wikidata Items into Items tab
+ */
+class BookmarkItemsAdapter(
+    val list: List,
+    val context: Context,
+) : RecyclerView.Adapter() {
+    class BookmarkItemViewHolder(
+        itemView: View,
+    ) : RecyclerView.ViewHolder(itemView) {
+        var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label)
+        var description: TextView = itemView.findViewById(R.id.description)
+        var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image)
+        var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item)
+    }
+
+    override fun onCreateViewHolder(
+        parent: ViewGroup,
+        viewType: Int,
+    ): BookmarkItemViewHolder {
+        val v: View =
+            LayoutInflater
+                .from(context)
+                .inflate(R.layout.item_depictions, parent, false)
+        return BookmarkItemViewHolder(v)
+    }
+
+    override fun onBindViewHolder(
+        holder: BookmarkItemViewHolder,
+        position: Int,
+    ) {
+        val depictedItem = list[position]
+        holder.depictsLabel.text = depictedItem.name
+        holder.description.text = depictedItem.description
+
+        if (depictedItem.imageUrl?.isNotBlank() == true) {
+            holder.depictsImage.setImageURI(depictedItem.imageUrl)
+        } else {
+            holder.depictsImage.setActualImageResource(R.drawable.ic_wikidata_logo_24dp)
+        }
+        holder.layout.setOnClickListener {
+            WikidataItemDetailsActivity.startYourself(context, depictedItem)
+        }
+    }
+
+    override fun getItemCount(): Int = list.size
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt
new file mode 100644
index 000000000..c532ed3cc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt
@@ -0,0 +1,101 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteQueryBuilder
+import android.net.Uri
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.TABLE_NAME
+import fr.free.nrw.commons.di.CommonsDaggerContentProvider
+import androidx.core.net.toUri
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_ID
+
+/**
+ * Handles private storage for bookmarked items
+ */
+class BookmarkItemsContentProvider : CommonsDaggerContentProvider() {
+    override fun getType(uri: Uri): String? = null
+
+    /**
+     * Queries the SQLite database for the bookmark items
+     * @param uri : contains the uri for bookmark items
+     * @param projection : contains the all fields of the table
+     * @param selection : handles Where
+     * @param selectionArgs : the condition of Where clause
+     * @param sortOrder : ascending or descending
+     */
+    override fun query(
+        uri: Uri, projection: Array?, selection: String?,
+        selectionArgs: Array?, sortOrder: String?
+    ): Cursor {
+        val queryBuilder = SQLiteQueryBuilder().apply {
+            tables = TABLE_NAME
+        }
+
+        return queryBuilder.query(
+            requireDb(), projection, selection,
+            selectionArgs, null, null, sortOrder
+        ).apply {
+            setNotificationUri(context?.contentResolver, uri)
+        }
+    }
+
+    /**
+     * Handles the update query of local SQLite Database
+     * @param uri : contains the uri for bookmark items
+     * @param contentValues : new values to be entered to db
+     * @param selection : handles Where
+     * @param selectionArgs : the condition of Where clause
+     */
+    override fun update(
+        uri: Uri, contentValues: ContentValues?,
+        selection: String?, selectionArgs: Array?
+    ): Int {
+        val rowsUpdated: Int
+        if (selection.isNullOrEmpty()) {
+            val id = uri.lastPathSegment!!.toInt()
+            rowsUpdated = requireDb().update(
+                TABLE_NAME,
+                contentValues,
+                "$COLUMN_ID = ?",
+                arrayOf(id.toString())
+            )
+        } else {
+            throw IllegalArgumentException(
+                "Parameter `selection` should be empty when updating an ID"
+            )
+        }
+
+        context?.contentResolver?.notifyChange(uri, null)
+        return rowsUpdated
+    }
+
+    /**
+     * Handles the insertion of new bookmark items record to local SQLite Database
+     */
+    override fun insert(uri: Uri, contentValues: ContentValues?): Uri? {
+        val id = requireDb().insert(TABLE_NAME, null, contentValues)
+        context?.contentResolver?.notifyChange(uri, null)
+        return "$BASE_URI/$id".toUri()
+    }
+
+
+    /**
+     * Handles the deletion of new bookmark items record to local SQLite Database
+     */
+    override fun delete(uri: Uri, s: String?, strings: Array?): Int {
+        val rows: Int = requireDb().delete(
+            TABLE_NAME,
+            "$COLUMN_ID = ?",
+            arrayOf(uri.lastPathSegment)
+        )
+       context?.contentResolver?.notifyChange(uri, null)
+        return rows
+    }
+
+    companion object {
+        private const val BASE_PATH = "bookmarksItems"
+        val BASE_URI: Uri = "content://${BuildConfig.BOOKMARK_ITEMS_AUTHORITY}/$BASE_PATH".toUri()
+        fun uriForName(id: String) = "$BASE_URI/$id".toUri()
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt
new file mode 100644
index 000000000..d1a9ef785
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt
@@ -0,0 +1,23 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Handles loading bookmarked items from Database
+ */
+@Singleton
+class BookmarkItemsController @Inject constructor() {
+    @JvmField
+    @Inject
+    var bookmarkItemsDao: BookmarkItemsDao? = null
+
+    /**
+     * Load from DB the bookmarked items
+     * @return a list of DepictedItem objects.
+     */
+    fun loadFavoritesItems(): List {
+        return bookmarkItemsDao?.getAllBookmarksItems() ?: emptyList()
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt
new file mode 100644
index 000000000..e21e1ac8f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt
@@ -0,0 +1,203 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import android.annotation.SuppressLint
+import android.content.ContentProviderClient
+import android.content.ContentValues
+import android.database.Cursor
+import android.os.RemoteException
+import androidx.core.content.contentValuesOf
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider.Companion.BASE_URI
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider.Companion.uriForName
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_DESCRIPTION_LIST
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_NAME_LIST
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_THUMBNAIL_LIST
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_DESCRIPTION
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_ID
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_IMAGE
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_INSTANCE_LIST
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_IS_SELECTED
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_NAME
+import fr.free.nrw.commons.category.CategoryItem
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+import fr.free.nrw.commons.utils.arrayToString
+import fr.free.nrw.commons.utils.getString
+import fr.free.nrw.commons.utils.getStringArray
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * Handles database operations for bookmarked items
+ */
+@Singleton
+class BookmarkItemsDao @Inject constructor(
+    @param:Named("bookmarksItem") private val clientProvider: Provider
+) {
+    /**
+     * Find all persisted items bookmarks on database
+     * @return list of bookmarks
+     */
+    fun getAllBookmarksItems(): List {
+        val items: MutableList = mutableListOf()
+        val db = clientProvider.get()
+        try {
+            db.query(
+                BASE_URI,
+                BookmarkItemsTable.ALL_FIELDS,
+                null,
+                arrayOf(),
+                null
+            ).use { cursor ->
+                while (cursor != null && cursor.moveToNext()) {
+                    items.add(fromCursor(cursor))
+                }
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+        return items
+    }
+
+
+    /**
+     * Look for a bookmark in database and in order to insert or delete it
+     * @param depictedItem : Bookmark object
+     * @return boolean : is bookmark now favorite ?
+     */
+    fun updateBookmarkItem(depictedItem: DepictedItem): Boolean {
+        val bookmarkExists = findBookmarkItem(depictedItem.id)
+        if (bookmarkExists) {
+            deleteBookmarkItem(depictedItem)
+        } else {
+            addBookmarkItem(depictedItem)
+        }
+        return !bookmarkExists
+    }
+
+    /**
+     * Add a Bookmark to database
+     * @param depictedItem : Bookmark to add
+     */
+    private fun addBookmarkItem(depictedItem: DepictedItem) {
+        val db = clientProvider.get()
+        try {
+            db.insert(BASE_URI, toContentValues(depictedItem))
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+    }
+
+    /**
+     * Delete a bookmark from database
+     * @param depictedItem : Bookmark to delete
+     */
+    private fun deleteBookmarkItem(depictedItem: DepictedItem) {
+        val db = clientProvider.get()
+        try {
+            db.delete(uriForName(depictedItem.id), null, null)
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+    }
+
+    /**
+     * Find a bookmark from database based on its name
+     * @param depictedItemID : Bookmark to find
+     * @return boolean : is bookmark in database ?
+     */
+    fun findBookmarkItem(depictedItemID: String?): Boolean {
+        if (depictedItemID == null) { //Avoiding NPE's
+            return false
+        }
+        val db = clientProvider.get()
+        try {
+            db.query(
+                BASE_URI,
+                BookmarkItemsTable.ALL_FIELDS,
+                COLUMN_ID + "=?",
+                arrayOf(depictedItemID),
+                null
+            ).use { cursor ->
+                if (cursor != null && cursor.moveToFirst()) {
+                    return true
+                }
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+        return false
+    }
+
+    /**
+     * Recives real data from cursor
+     * @param cursor : Object for storing database data
+     * @return DepictedItem
+     */
+    @SuppressLint("Range")
+    fun fromCursor(cursor: Cursor) = with(cursor) {
+        var name = getString(COLUMN_NAME)
+        if (name == null) {
+            name = ""
+        }
+
+        var id = getString(COLUMN_ID)
+        if (id == null) {
+            id = ""
+        }
+
+        DepictedItem(
+            name,
+            getString(COLUMN_DESCRIPTION),
+            getString(COLUMN_IMAGE),
+            getStringArray(COLUMN_INSTANCE_LIST),
+            convertToCategoryItems(
+                getStringArray(COLUMN_CATEGORIES_NAME_LIST),
+                getStringArray(COLUMN_CATEGORIES_DESCRIPTION_LIST),
+                getStringArray(COLUMN_CATEGORIES_THUMBNAIL_LIST)
+            ),
+            getString(COLUMN_IS_SELECTED).toBoolean(),
+            id
+        )
+    }
+
+    private fun convertToCategoryItems(
+        categoryNameList: List,
+        categoryDescriptionList: List,
+        categoryThumbnailList: List
+    ): List = categoryNameList.mapIndexed { index, name ->
+        CategoryItem(
+            name = name,
+            description = categoryDescriptionList.getOrNull(index),
+            thumbnail = categoryThumbnailList.getOrNull(index),
+            isSelected = false
+        )
+    }
+
+    /**
+     * Takes data from DepictedItem and create a content value object
+     * @param depictedItem depicted item
+     * @return ContentValues
+     */
+    private fun toContentValues(depictedItem: DepictedItem): ContentValues {
+        return contentValuesOf(
+            COLUMN_NAME to depictedItem.name,
+            COLUMN_DESCRIPTION to depictedItem.description,
+            COLUMN_IMAGE to depictedItem.imageUrl,
+            COLUMN_INSTANCE_LIST to arrayToString(depictedItem.instanceOfs),
+            COLUMN_CATEGORIES_NAME_LIST to arrayToString(depictedItem.commonsCategories.map { it.name }),
+            COLUMN_CATEGORIES_DESCRIPTION_LIST to arrayToString(depictedItem.commonsCategories.map { it.description }),
+            COLUMN_CATEGORIES_THUMBNAIL_LIST to arrayToString(depictedItem.commonsCategories.map { it.thumbnail }),
+            COLUMN_IS_SELECTED to depictedItem.isSelected,
+            COLUMN_ID to depictedItem.id,
+        )
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt
new file mode 100644
index 000000000..aa9dcccc0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt
@@ -0,0 +1,62 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import dagger.android.support.DaggerFragment
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.databinding.FragmentBookmarksItemsBinding
+import javax.inject.Inject
+
+/**
+ * Tab fragment to show list of bookmarked Wikidata Items
+ */
+class BookmarkItemsFragment : DaggerFragment() {
+    private var binding: FragmentBookmarksItemsBinding? = null
+
+    @JvmField
+    @Inject
+    var controller: BookmarkItemsController? = null
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        binding = FragmentBookmarksItemsBinding.inflate(inflater, container, false)
+        return binding!!.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        initList(requireContext())
+    }
+
+    override fun onResume() {
+        super.onResume()
+        initList(requireContext())
+    }
+
+    /**
+     * Get list of DepictedItem and sets to the adapter
+     * @param context context
+     */
+    private fun initList(context: Context) {
+        val depictItems = controller!!.loadFavoritesItems()
+        binding!!.listView.adapter = BookmarkItemsAdapter(depictItems, context)
+        binding!!.loadingImagesProgressBar.visibility = View.GONE
+        if (depictItems.isEmpty()) {
+            binding!!.statusMessage.setText(R.string.bookmark_empty)
+            binding!!.statusMessage.visibility = View.VISIBLE
+        } else {
+            binding!!.statusMessage.visibility = View.GONE
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        binding = null
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt
new file mode 100644
index 000000000..b1b03c71b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt
@@ -0,0 +1,90 @@
+package fr.free.nrw.commons.bookmarks.items
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Table of bookmarksItems data
+ */
+object BookmarkItemsTable {
+    const val TABLE_NAME = "bookmarksItems"
+    const val COLUMN_NAME = "item_name"
+    const val COLUMN_DESCRIPTION = "item_description"
+    const val COLUMN_IMAGE = "item_image_url"
+    const val COLUMN_INSTANCE_LIST = "item_instance_of"
+    const val COLUMN_CATEGORIES_NAME_LIST = "item_name_categories"
+    const val COLUMN_CATEGORIES_DESCRIPTION_LIST = "item_description_categories"
+    const val COLUMN_CATEGORIES_THUMBNAIL_LIST = "item_thumbnail_categories"
+    const val COLUMN_IS_SELECTED = "item_is_selected"
+    const val COLUMN_ID = "item_id"
+
+    val ALL_FIELDS = arrayOf(
+        COLUMN_NAME,
+        COLUMN_DESCRIPTION,
+        COLUMN_IMAGE,
+        COLUMN_INSTANCE_LIST,
+        COLUMN_CATEGORIES_NAME_LIST,
+        COLUMN_CATEGORIES_DESCRIPTION_LIST,
+        COLUMN_CATEGORIES_THUMBNAIL_LIST,
+        COLUMN_IS_SELECTED,
+        COLUMN_ID
+    )
+
+    const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+    val CREATE_TABLE_STATEMENT =
+        """CREATE TABLE $TABLE_NAME (
+             $COLUMN_NAME STRING,
+             $COLUMN_DESCRIPTION STRING,
+             $COLUMN_IMAGE STRING,
+             $COLUMN_INSTANCE_LIST STRING,
+             $COLUMN_CATEGORIES_NAME_LIST STRING,
+             $COLUMN_CATEGORIES_DESCRIPTION_LIST STRING,
+             $COLUMN_CATEGORIES_THUMBNAIL_LIST STRING,
+             $COLUMN_IS_SELECTED STRING,
+             $COLUMN_ID STRING PRIMARY KEY
+           );""".trimIndent()
+
+    /**
+     * Creates table
+     *
+     * @param db SQLiteDatabase
+     */
+    fun onCreate(db: SQLiteDatabase) {
+        db.execSQL(CREATE_TABLE_STATEMENT)
+    }
+
+    /**
+     * Deletes database
+     *
+     * @param db SQLiteDatabase
+     */
+    fun onDelete(db: SQLiteDatabase) {
+        db.execSQL(DROP_TABLE_STATEMENT)
+        onCreate(db)
+    }
+
+    /**
+     * Updates database
+     *
+     * @param db   SQLiteDatabase
+     * @param from starting
+     * @param to   end
+     */
+    fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+        if (from == to) {
+            return
+        }
+
+        if (from < 18) {
+            // doesn't exist yet
+            onUpdate(db, from + 1, to)
+            return
+        }
+
+        if (from == 18) {
+            // table added in version 19
+            onCreate(db)
+            onUpdate(db, from + 1, to)
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java
deleted file mode 100644
index 8c9b559d4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java
+++ /dev/null
@@ -1,119 +0,0 @@
-package fr.free.nrw.commons.bookmarks.locations;
-
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteQueryBuilder;
-// We can get uri using java.Net.Uri, but andoid implimentation is faster (but it's forgiving with handling exceptions though)
-import android.net.Uri;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.data.DBOpenHelper;
-import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
-import timber.log.Timber;
-
-import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.COLUMN_NAME;
-import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.TABLE_NAME;
-
-/**
- * Handles private storage for Bookmark locations
- */
-public class BookmarkLocationsContentProvider extends CommonsDaggerContentProvider {
-
-    private static final String BASE_PATH = "bookmarksLocations";
-    public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY + "/" + BASE_PATH);
-
-    /**
-     * Append bookmark locations name to the base uri 
-     */
-    public static Uri uriForName(String name) {
-        return Uri.parse(BASE_URI.toString() + "/" + name);
-    }
-
-    @Inject DBOpenHelper dbOpenHelper;
-
-    @Override
-    public String getType(@NonNull Uri uri) {
-        return null;
-    }
-
-    /**
-     * Queries the SQLite database for the bookmark locations
-     * @param uri : contains the uri for bookmark locations
-     * @param projection
-     * @param selection : handles Where
-     * @param selectionArgs : the condition of Where clause
-     * @param sortOrder : ascending or descending
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Cursor query(@NonNull Uri uri, String[] projection, String selection,
-                        String[] selectionArgs, String sortOrder) {
-        SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
-        queryBuilder.setTables(TABLE_NAME);
-
-        SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
-        Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
-        cursor.setNotificationUri(getContext().getContentResolver(), uri);
-
-        return cursor;
-    }
-
-    /**
-     * Handles the update query of local SQLite Database 
-     * @param uri : contains the uri for bookmark locations
-     * @param contentValues : new values to be entered to db
-     * @param selection : handles Where
-     * @param selectionArgs : the condition of Where clause
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
-                      String[] selectionArgs) {
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        int rowsUpdated;
-        if (TextUtils.isEmpty(selection)) {
-            int id = Integer.valueOf(uri.getLastPathSegment());
-            rowsUpdated = sqlDB.update(TABLE_NAME,
-                    contentValues,
-                    COLUMN_NAME + " = ?",
-                    new String[]{String.valueOf(id)});
-        } else {
-            throw new IllegalArgumentException(
-                    "Parameter `selection` should be empty when updating an ID");
-        }
-        getContext().getContentResolver().notifyChange(uri, null);
-        return rowsUpdated;
-    }
-
-    /**
-     * Handles the insertion of new bookmark locations record to local SQLite Database
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        long id = sqlDB.insert(BookmarkLocationsDao.Table.TABLE_NAME, null, contentValues);
-        getContext().getContentResolver().notifyChange(uri, null);
-        return Uri.parse(BASE_URI + "/" + id);
-    }
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int delete(@NonNull Uri uri, String s, String[] strings) {
-        int rows;
-        SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
-        Timber.d("Deleting bookmark name %s", uri.getLastPathSegment());
-        rows = db.delete(TABLE_NAME,
-                "location_name = ?",
-                new String[]{uri.getLastPathSegment()}
-        );
-        getContext().getContentResolver().notifyChange(uri, null);
-        return rows;
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java
deleted file mode 100644
index 6e4c17c2e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package fr.free.nrw.commons.bookmarks.locations;
-
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.nearby.Place;
-
-@Singleton
-public class BookmarkLocationsController {
-
-    @Inject
-    BookmarkLocationsDao bookmarkLocationDao;
-
-    @Inject
-    public BookmarkLocationsController() {}
-
-    /**
-     * Load from DB the bookmarked locations
-     * @return a list of Place objects.
-     */
-    public List loadFavoritesLocations() {
-        return bookmarkLocationDao.getAllBookmarksLocations();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt
new file mode 100644
index 000000000..81ec80214
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt
@@ -0,0 +1,20 @@
+package fr.free.nrw.commons.bookmarks.locations
+
+import fr.free.nrw.commons.nearby.Place
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BookmarkLocationsController @Inject constructor(
+    private val bookmarkLocationDao: BookmarkLocationsDao
+) {
+
+    /**
+     * Load bookmarked locations from the database.
+     * @return a list of Place objects.
+     */
+    suspend fun loadFavoritesLocations(): List =
+        bookmarkLocationDao.getAllBookmarksLocationsPlace()
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java
deleted file mode 100644
index 402b7d370..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java
+++ /dev/null
@@ -1,278 +0,0 @@
-package fr.free.nrw.commons.bookmarks.locations;
-
-import android.content.ContentProviderClient;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-import android.os.RemoteException;
-
-import androidx.annotation.NonNull;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Provider;
-
-import fr.free.nrw.commons.location.LatLng;
-import fr.free.nrw.commons.nearby.Label;
-import fr.free.nrw.commons.nearby.Place;
-import fr.free.nrw.commons.nearby.Sitelinks;
-import timber.log.Timber;
-
-import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider.BASE_URI;
-
-public class BookmarkLocationsDao {
-
-    private final Provider clientProvider;
-
-    @Inject
-    public BookmarkLocationsDao(@Named("bookmarksLocation") Provider clientProvider) {
-        this.clientProvider = clientProvider;
-    }
-
-    /**
-     *  Find all persisted locations bookmarks on database
-     *
-     * @return list of Place
-     */
-    @NonNull
-    public List getAllBookmarksLocations() {
-        List items = new ArrayList<>();
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    BookmarkLocationsContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    null,
-                    new String[]{},
-                    null);
-            while (cursor != null && cursor.moveToNext()) {
-                items.add(fromCursor(cursor));
-            }
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return items;
-    }
-
-    /**
-     * Look for a place in bookmarks table in order to insert or delete it
-     *
-     * @param bookmarkLocation : Place object
-     * @return is Place now fav ?
-     */
-    public boolean updateBookmarkLocation(Place bookmarkLocation) {
-        boolean bookmarkExists = findBookmarkLocation(bookmarkLocation);
-        if (bookmarkExists) {
-            deleteBookmarkLocation(bookmarkLocation);
-        } else {
-            addBookmarkLocation(bookmarkLocation);
-        }
-        return !bookmarkExists;
-    }
-
-    /**
-     * Add a Place to bookmarks table
-     *
-     * @param bookmarkLocation : Place to add
-     */
-    private void addBookmarkLocation(Place bookmarkLocation) {
-        ContentProviderClient db = clientProvider.get();
-        try {
-            db.insert(BASE_URI, toContentValues(bookmarkLocation));
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            db.release();
-        }
-    }
-
-    /**
-     * Delete a Place from bookmarks table
-     *
-     * @param bookmarkLocation : Place to delete
-     */
-    private void deleteBookmarkLocation(Place bookmarkLocation) {
-        ContentProviderClient db = clientProvider.get();
-        try {
-            db.delete(BookmarkLocationsContentProvider.uriForName(bookmarkLocation.name), null, null);
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            db.release();
-        }
-    }
-
-    /**
-     * Find a Place from database based on its name
-     *
-     * @param bookmarkLocation : Place to find
-     * @return boolean : is Place in database ?
-     */
-    public boolean findBookmarkLocation(Place bookmarkLocation) {
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    BookmarkLocationsContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    Table.COLUMN_NAME + "=?",
-                    new String[]{bookmarkLocation.name},
-                    null);
-            if (cursor != null && cursor.moveToFirst()) {
-                return true;
-            }
-        } catch (RemoteException e) {
-            // This feels lazy, but to hell with checked exceptions. :)
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return false;
-    }
-
-    @NonNull
-    Place fromCursor(Cursor cursor) {
-        LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)),
-                cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
-
-        Sitelinks.Builder builder = new Sitelinks.Builder();
-        builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK)));
-        builder.setWikidataLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIDATA_LINK)));
-        builder.setCommonsLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_COMMONS_LINK)));
-
-        return new Place(
-                cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
-                Label.fromText((cursor.getString(cursor.getColumnIndex(Table.COLUMN_LABEL_TEXT)))),
-                cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
-                location,
-                cursor.getString(cursor.getColumnIndex(Table.COLUMN_CATEGORY)),
-                builder.build(),
-                null,
-                null
-        );
-        // TODO: add pic and destroyed to bookmark location dao
-    }
-
-    private ContentValues toContentValues(Place bookmarkLocation) {
-        ContentValues cv = new ContentValues();
-        cv.put(BookmarkLocationsDao.Table.COLUMN_NAME, bookmarkLocation.getName());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_DESCRIPTION, bookmarkLocation.getLongDescription());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_CATEGORY, bookmarkLocation.getCategory());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel().getText());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel().getIcon());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIPEDIA_LINK, bookmarkLocation.siteLinks.getWikipediaLink().toString());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIDATA_LINK, bookmarkLocation.siteLinks.getWikidataLink().toString());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_COMMONS_LINK, bookmarkLocation.siteLinks.getCommonsLink().toString());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_LAT, bookmarkLocation.location.getLatitude());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_LONG, bookmarkLocation.location.getLongitude());
-        cv.put(BookmarkLocationsDao.Table.COLUMN_PIC, bookmarkLocation.pic);
-        return cv;
-    }
-
-    public static class Table {
-        public static final String TABLE_NAME = "bookmarksLocations";
-
-        static final String COLUMN_NAME = "location_name";
-        static final String COLUMN_DESCRIPTION = "location_description";
-        static final String COLUMN_LAT = "location_lat";
-        static final String COLUMN_LONG = "location_long";
-        static final String COLUMN_CATEGORY = "location_category";
-        static final String COLUMN_LABEL_TEXT = "location_label_text";
-        static final String COLUMN_LABEL_ICON = "location_label_icon";
-        static final String COLUMN_IMAGE_URL = "location_image_url";
-        static final String COLUMN_WIKIPEDIA_LINK = "location_wikipedia_link";
-        static final String COLUMN_WIKIDATA_LINK = "location_wikidata_link";
-        static final String COLUMN_COMMONS_LINK = "location_commons_link";
-        static final String COLUMN_PIC = "location_pic";
-
-        // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
-        public static final String[] ALL_FIELDS = {
-                COLUMN_NAME,
-                COLUMN_DESCRIPTION,
-                COLUMN_CATEGORY,
-                COLUMN_LABEL_TEXT,
-                COLUMN_LABEL_ICON,
-                COLUMN_LAT,
-                COLUMN_LONG,
-                COLUMN_IMAGE_URL,
-                COLUMN_WIKIPEDIA_LINK,
-                COLUMN_WIKIDATA_LINK,
-                COLUMN_COMMONS_LINK,
-                COLUMN_PIC
-        };
-
-        static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
-
-        static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
-                + COLUMN_NAME + " STRING PRIMARY KEY,"
-                + COLUMN_DESCRIPTION + " STRING,"
-                + COLUMN_CATEGORY + " STRING,"
-                + COLUMN_LABEL_TEXT + " STRING,"
-                + COLUMN_LABEL_ICON + " INTEGER,"
-                + COLUMN_LAT + " DOUBLE,"
-                + COLUMN_LONG + " DOUBLE,"
-                + COLUMN_IMAGE_URL + " STRING,"
-                + COLUMN_WIKIPEDIA_LINK + " STRING,"
-                + COLUMN_WIKIDATA_LINK + " STRING,"
-                + COLUMN_COMMONS_LINK + " STRING,"
-                + COLUMN_PIC + " STRING"
-                + ");";
-
-        public static void onCreate(SQLiteDatabase db) {
-            db.execSQL(CREATE_TABLE_STATEMENT);
-        }
-
-        public static void onDelete(SQLiteDatabase db) {
-            db.execSQL(DROP_TABLE_STATEMENT);
-            onCreate(db);
-        }
-
-        public static void onUpdate(SQLiteDatabase db, int from, int to) {
-            Timber.d("bookmarksLocations db is updated from:"+from+", to:"+to);
-            if (from == to) {
-                return;
-            }
-            if (from < 7) {
-                // doesn't exist yet
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-            if (from == 7) {
-                // table added in version 8
-                onCreate(db);
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-            if (from == 8) {
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-            if (from == 10) {
-                //This is safe, and can be called clean, as we/I do not remember the appropriate version for this
-                //We are anyways switching to room, these things won't be necessary then
-                try {
-                    db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_pic STRING;");
-                }catch (SQLiteException exception){
-                    Timber.e(exception);//
-                }
-                return;
-            }
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt
new file mode 100644
index 000000000..2fa65b2d9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt
@@ -0,0 +1,65 @@
+package fr.free.nrw.commons.bookmarks.locations
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import fr.free.nrw.commons.nearby.NearbyController
+import fr.free.nrw.commons.nearby.Place
+
+/**
+ * DAO for managing bookmark locations in the database.
+ */
+@Dao
+abstract class BookmarkLocationsDao {
+
+    /**
+     * Adds or updates a bookmark location in the database.
+     */
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    abstract suspend fun addBookmarkLocation(bookmarkLocation: BookmarksLocations)
+
+    /**
+     * Fetches all bookmark locations from the database.
+     */
+    @Query("SELECT * FROM bookmarks_locations")
+    abstract suspend fun getAllBookmarksLocations(): List
+
+    /**
+     * Checks if a bookmark location exists by name.
+     */
+    @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_locations WHERE location_name = :name)")
+    abstract suspend fun findBookmarkLocation(name: String): Boolean
+
+    /**
+     * Deletes a bookmark location from the database.
+     */
+    @Delete
+    abstract suspend fun deleteBookmarkLocation(bookmarkLocation: BookmarksLocations)
+
+    /**
+     * Adds or removes a bookmark location and updates markers.
+     * @return `true` if added, `false` if removed.
+     */
+    suspend fun updateBookmarkLocation(bookmarkLocation: Place): Boolean {
+        val exists = findBookmarkLocation(bookmarkLocation.name)
+
+        if (exists) {
+            deleteBookmarkLocation(bookmarkLocation.toBookmarksLocations())
+            NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, false)
+        } else {
+            addBookmarkLocation(bookmarkLocation.toBookmarksLocations())
+            NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, true)
+        }
+
+        return !exists
+    }
+
+    /**
+     * Fetches all bookmark locations as `Place` objects.
+     */
+    suspend fun getAllBookmarksLocationsPlace(): List {
+        return getAllBookmarksLocations().map { it.toPlace() }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java
deleted file mode 100644
index d3f193784..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package fr.free.nrw.commons.bookmarks.locations;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.pedrogomez.renderers.RVRendererAdapter;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import dagger.android.support.DaggerFragment;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.contributions.ContributionController;
-import fr.free.nrw.commons.nearby.NearbyAdapterFactory;
-import fr.free.nrw.commons.nearby.Place;
-
-public class BookmarkLocationsFragment extends DaggerFragment {
-
-    @BindView(R.id.statusMessage) TextView statusTextView;
-    @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
-    @BindView(R.id.listView) RecyclerView recyclerView;
-    @BindView(R.id.parentLayout) RelativeLayout parentLayout;
-
-    @Inject BookmarkLocationsController controller;
-    private NearbyAdapterFactory adapterFactory;
-    @Inject ContributionController contributionController;
-
-    /**
-     * Create an instance of the fragment with the right bundle parameters
-     * @return an instance of the fragment
-     */
-    public static BookmarkLocationsFragment newInstance() {
-        return new BookmarkLocationsFragment();
-    }
-
-    @Override
-    public View onCreateView(
-            @NonNull LayoutInflater inflater,
-            ViewGroup container,
-            Bundle savedInstanceState
-    ) {
-        View v = inflater.inflate(R.layout.fragment_bookmarks_locations, container, false);
-        ButterKnife.bind(this, v);
-        adapterFactory = new NearbyAdapterFactory(this, contributionController);
-        return v;
-    }
-
-    @Override
-    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-        progressBar.setVisibility(View.VISIBLE);
-        recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
-        recyclerView.setAdapter(adapterFactory.create(new ArrayList<>(), this::initList));
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-        initList();
-    }
-
-    /**
-     * Initialize the recycler view with bookmarked locations
-     */
-    private void initList() {
-        List places = controller.loadFavoritesLocations();
-        adapterFactory.updateAdapterData(places, (RVRendererAdapter) recyclerView.getAdapter());
-        progressBar.setVisibility(View.GONE);
-        if (places.size() <= 0) {
-            statusTextView.setText(R.string.bookmark_empty);
-            statusTextView.setVisibility(View.VISIBLE);
-        } else {
-            statusTextView.setVisibility(View.GONE);
-        }
-    }
-
-    @Override
-    public void onActivityResult(int requestCode, int resultCode, Intent data) {
-        contributionController.handleActivityResult(getActivity(), requestCode, resultCode, data);
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt
new file mode 100644
index 000000000..f10e02ebc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt
@@ -0,0 +1,151 @@
+package fr.free.nrw.commons.bookmarks.locations
+
+import android.Manifest.permission
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import dagger.android.support.DaggerFragment
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.contributions.ContributionController
+import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding
+import fr.free.nrw.commons.nearby.Place
+import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions
+import fr.free.nrw.commons.nearby.fragments.PlaceAdapter
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+class BookmarkLocationsFragment : DaggerFragment() {
+
+    private var binding: FragmentBookmarksLocationsBinding? = null
+
+    @Inject lateinit var controller: BookmarkLocationsController
+    @Inject lateinit var contributionController: ContributionController
+    @Inject lateinit var bookmarkLocationDao: BookmarkLocationsDao
+    @Inject lateinit var commonPlaceClickActions: CommonPlaceClickActions
+
+    private lateinit var inAppCameraLocationPermissionLauncher:
+            ActivityResultLauncher>
+    private lateinit var adapter: PlaceAdapter
+
+    private val cameraPickLauncherForResult =
+        registerForActivityResult(StartActivityForResult()) { result ->
+            contributionController.handleActivityResultWithCallback(
+                requireActivity()
+            ) { callbacks ->
+                contributionController.onPictureReturnedFromCamera(
+                    result,
+                    requireActivity(),
+                    callbacks
+                )
+            }
+        }
+
+    private val galleryPickLauncherForResult =
+        registerForActivityResult(StartActivityForResult()) { result ->
+            contributionController.handleActivityResultWithCallback(
+                requireActivity()
+            ) { callbacks ->
+                contributionController.onPictureReturnedFromGallery(
+                    result,
+                    requireActivity(),
+                    callbacks
+                )
+            }
+        }
+
+    companion object {
+        fun newInstance(): BookmarkLocationsFragment {
+            return BookmarkLocationsFragment()
+        }
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false)
+        return binding?.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        binding?.loadingImagesProgressBar?.visibility = View.VISIBLE
+        binding?.listView?.layoutManager = LinearLayoutManager(context)
+
+        inAppCameraLocationPermissionLauncher =
+            registerForActivityResult(RequestMultiplePermissions()) { result ->
+                val areAllGranted = result.values.all { it }
+
+                if (areAllGranted) {
+                    contributionController.locationPermissionCallback?.onLocationPermissionGranted()
+                } else {
+                    if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
+                        contributionController.handleShowRationaleFlowCameraLocation(
+                            requireActivity(),
+                            inAppCameraLocationPermissionLauncher,
+                            cameraPickLauncherForResult
+                        )
+                    } else {
+                        contributionController.locationPermissionCallback
+                            ?.onLocationPermissionDenied(
+                                getString(R.string.in_app_camera_location_permission_denied)
+                            )
+                    }
+                }
+            }
+
+        adapter = PlaceAdapter(
+            bookmarkLocationDao,
+            lifecycleScope,
+            { },
+            { place, _ ->
+                adapter.remove(place)
+            },
+            commonPlaceClickActions,
+            inAppCameraLocationPermissionLauncher,
+            galleryPickLauncherForResult,
+            cameraPickLauncherForResult
+        )
+        binding?.listView?.adapter = adapter
+    }
+
+    override fun onResume() {
+        super.onResume()
+        initList()
+    }
+
+    fun initList() {
+        var places: List
+        if(view != null) {
+            viewLifecycleOwner.lifecycleScope.launch {
+                places = controller.loadFavoritesLocations()
+                updateUIList(places)
+            }
+        }
+    }
+
+    private fun updateUIList(places: List) {
+        adapter.items = places
+        binding?.loadingImagesProgressBar?.visibility = View.GONE
+        if (places.isEmpty()) {
+            binding?.statusMessage?.text = getString(R.string.bookmark_empty)
+            binding?.statusMessage?.visibility = View.VISIBLE
+        } else {
+            binding?.statusMessage?.visibility = View.GONE
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        // Make sure to null out the binding to avoid memory leaks
+        binding = null
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt
new file mode 100644
index 000000000..b22723c0f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt
@@ -0,0 +1,15 @@
+package fr.free.nrw.commons.bookmarks.locations
+
+import androidx.lifecycle.ViewModel
+import fr.free.nrw.commons.nearby.Place
+import kotlinx.coroutines.flow.Flow
+
+class BookmarkLocationsViewModel(
+    private val bookmarkLocationsDao: BookmarkLocationsDao
+): ViewModel() {
+
+//    fun getAllBookmarkLocations(): List {
+//        return bookmarkLocationsDao.getAllBookmarksLocationsPlace()
+//    }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt
new file mode 100644
index 000000000..66d670169
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt
@@ -0,0 +1,72 @@
+package fr.free.nrw.commons.bookmarks.locations
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Label
+import fr.free.nrw.commons.nearby.Place
+import fr.free.nrw.commons.nearby.Sitelinks
+
+@Entity(tableName = "bookmarks_locations")
+data class BookmarksLocations(
+    @PrimaryKey @ColumnInfo(name = "location_name") val locationName: String,
+    @ColumnInfo(name = "location_language") val locationLanguage: String,
+    @ColumnInfo(name = "location_description") val locationDescription: String,
+    @ColumnInfo(name = "location_lat") val locationLat: Double,
+    @ColumnInfo(name = "location_long") val locationLong: Double,
+    @ColumnInfo(name = "location_category") val locationCategory: String,
+    @ColumnInfo(name = "location_label_text") val locationLabelText: String,
+    @ColumnInfo(name = "location_label_icon") val locationLabelIcon: Int?,
+    @ColumnInfo(name = "location_image_url") val locationImageUrl: String,
+    @ColumnInfo(name = "location_wikipedia_link") val locationWikipediaLink: String,
+    @ColumnInfo(name = "location_wikidata_link") val locationWikidataLink: String,
+    @ColumnInfo(name = "location_commons_link") val locationCommonsLink: String,
+    @ColumnInfo(name = "location_pic") val locationPic: String,
+    @ColumnInfo(name = "location_exists") val locationExists: Boolean
+)
+
+fun BookmarksLocations.toPlace(): Place {
+    val location = LatLng(
+        locationLat,
+        locationLong,
+        1F
+    )
+
+    val builder = Sitelinks.Builder().apply {
+        setWikipediaLink(locationWikipediaLink)
+        setWikidataLink(locationWikidataLink)
+        setCommonsLink(locationCommonsLink)
+    }
+
+    return Place(
+        locationLanguage,
+        locationName,
+        Label.fromText(locationLabelText),
+        locationDescription,
+        location,
+        locationCategory,
+        builder.build(),
+        locationPic,
+        locationExists
+    )
+}
+
+fun Place.toBookmarksLocations(): BookmarksLocations {
+    return BookmarksLocations(
+        locationName = name,
+        locationLanguage = language,
+        locationDescription = longDescription,
+        locationCategory = category,
+        locationLat = location.latitude,
+        locationLong = location.longitude,
+        locationLabelText = label?.text ?: "",
+        locationLabelIcon = label?.icon,
+        locationImageUrl = pic,
+        locationWikipediaLink = siteLinks.wikipediaLink.toString(),
+        locationWikidataLink = siteLinks.wikidataLink.toString(),
+        locationCommonsLink = siteLinks.commonsLink.toString(),
+        locationPic = pic,
+        locationExists = exists
+    )
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt
new file mode 100644
index 000000000..630889c01
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt
@@ -0,0 +1,26 @@
+package fr.free.nrw.commons.bookmarks.models
+
+import android.net.Uri
+
+class Bookmark(
+    mediaName: String?,
+    mediaCreator: String?,
+    /**
+     * Gets or Sets the content URI - marking this bookmark as already saved in the database
+     * @return content URI
+     * contentUri the content URI
+     */
+    var contentUri: Uri?,
+) {
+    /**
+     * Gets the media name
+     * @return the media name
+     */
+    val mediaName: String = mediaName ?: ""
+
+    /**
+     * Gets media creator
+     * @return creator name
+     */
+    val mediaCreator: String = mediaCreator ?: ""
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.java
deleted file mode 100644
index 2aac07902..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package fr.free.nrw.commons.bookmarks.pictures;
-
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteQueryBuilder;
-// We can get uri using java.Net.Uri, but andoid implimentation is faster (but it's forgiving with handling exceptions though)
-import android.net.Uri;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.data.DBOpenHelper;
-import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
-import timber.log.Timber;
-
-import static fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao.Table.COLUMN_MEDIA_NAME;
-import static fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao.Table.TABLE_NAME;
-
-/**
- * Handles private storage for Bookmark pictures
- */
-public class BookmarkPicturesContentProvider extends CommonsDaggerContentProvider {
-
-    private static final String BASE_PATH = "bookmarks";
-    public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.BOOKMARK_AUTHORITY + "/" + BASE_PATH);
-
-    /**
-     * Append bookmark pictures name to the base uri 
-     */
-    public static Uri uriForName(String name) {
-        return Uri.parse(BASE_URI.toString() + "/" + name);
-    }
-
-    @Inject
-    DBOpenHelper dbOpenHelper;
-
-    @Override
-    public String getType(@NonNull Uri uri) {
-        return null;
-    }
-
-    /**
-     * Queries the SQLite database for the bookmark pictures
-     * @param uri : contains the uri for bookmark pictures
-     * @param projection
-     * @param selection : handles Where
-     * @param selectionArgs : the condition of Where clause
-     * @param sortOrder : ascending or descending
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Cursor query(@NonNull Uri uri, String[] projection, String selection,
-                        String[] selectionArgs, String sortOrder) {
-        SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
-        queryBuilder.setTables(TABLE_NAME);
-
-        SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
-        Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
-        cursor.setNotificationUri(getContext().getContentResolver(), uri);
-
-        return cursor;
-    }
-
-    /**
-     * Handles the update query of local SQLite Database 
-     * @param uri : contains the uri for bookmark pictures
-     * @param contentValues : new values to be entered to db
-     * @param selection : handles Where
-     * @param selectionArgs : the condition of Where clause
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
-                      String[] selectionArgs) {
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        int rowsUpdated;
-        if (TextUtils.isEmpty(selection)) {
-            int id = Integer.valueOf(uri.getLastPathSegment());
-            rowsUpdated = sqlDB.update(TABLE_NAME,
-                    contentValues,
-                    COLUMN_MEDIA_NAME + " = ?",
-                    new String[]{String.valueOf(id)});
-        } else {
-            throw new IllegalArgumentException(
-                    "Parameter `selection` should be empty when updating an ID");
-        }
-        getContext().getContentResolver().notifyChange(uri, null);
-        return rowsUpdated;
-    }
-
-    /**
-     * Handles the insertion of new bookmark pictures record to local SQLite Database
-     */
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        long id = sqlDB.insert(BookmarkPicturesDao.Table.TABLE_NAME, null, contentValues);
-        getContext().getContentResolver().notifyChange(uri, null);
-        return Uri.parse(BASE_URI + "/" + id);
-    }
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int delete(@NonNull Uri uri, String s, String[] strings) {
-        int rows;
-        SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
-        Timber.d("Deleting bookmark name %s", uri.getLastPathSegment());
-        rows = db.delete(TABLE_NAME,
-                "media_name = ?",
-                new String[]{uri.getLastPathSegment()}
-        );
-        getContext().getContentResolver().notifyChange(uri, null);
-        return rows;
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt
new file mode 100644
index 000000000..a47eed8ca
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt
@@ -0,0 +1,100 @@
+package fr.free.nrw.commons.bookmarks.pictures
+
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteQueryBuilder
+import android.net.Uri
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.di.CommonsDaggerContentProvider
+import androidx.core.net.toUri
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_MEDIA_NAME
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.TABLE_NAME
+
+/**
+ * Handles private storage for Bookmark pictures
+ */
+class BookmarkPicturesContentProvider : CommonsDaggerContentProvider() {
+    override fun getType(uri: Uri): String? = null
+
+    /**
+     * Queries the SQLite database for the bookmark pictures
+     * @param uri : contains the uri for bookmark pictures
+     * @param projection
+     * @param selection : handles Where
+     * @param selectionArgs : the condition of Where clause
+     * @param sortOrder : ascending or descending
+     */
+    override fun query(
+        uri: Uri, projection: Array?, selection: String?,
+        selectionArgs: Array?, sortOrder: String?
+    ): Cursor {
+        val queryBuilder = SQLiteQueryBuilder().apply {
+            tables = TABLE_NAME
+        }
+
+        val cursor = queryBuilder.query(
+            requireDb(), projection, selection,
+            selectionArgs, null, null, sortOrder
+        )
+        cursor.setNotificationUri(context?.contentResolver, uri)
+
+        return cursor
+    }
+
+    /**
+     * Handles the update query of local SQLite Database
+     * @param uri : contains the uri for bookmark pictures
+     * @param contentValues : new values to be entered to db
+     * @param selection : handles Where
+     * @param selectionArgs : the condition of Where clause
+     */
+    override fun update(
+        uri: Uri, contentValues: ContentValues?, selection: String?,
+        selectionArgs: Array?
+    ): Int {
+        val rowsUpdated: Int
+        if (selection.isNullOrEmpty()) {
+            val id = uri.lastPathSegment!!.toInt()
+            rowsUpdated = requireDb().update(
+                TABLE_NAME,
+                contentValues,
+                "$COLUMN_MEDIA_NAME = ?",
+                arrayOf(id.toString())
+            )
+        } else {
+            throw IllegalArgumentException(
+                "Parameter `selection` should be empty when updating an ID"
+            )
+        }
+        context?.contentResolver?.notifyChange(uri, null)
+        return rowsUpdated
+    }
+
+    /**
+     * Handles the insertion of new bookmark pictures record to local SQLite Database
+     */
+    override fun insert(uri: Uri, contentValues: ContentValues?): Uri {
+        val id = requireDb().insert(TABLE_NAME, null, contentValues)
+        context?.contentResolver?.notifyChange(uri, null)
+        return "$BASE_URI/$id".toUri()
+    }
+
+    override fun delete(uri: Uri, s: String?, strings: Array?): Int {
+        val rows: Int = requireDb().delete(
+            TABLE_NAME,
+            "media_name = ?",
+            arrayOf(uri.lastPathSegment)
+        )
+        context?.contentResolver?.notifyChange(uri, null)
+        return rows
+    }
+
+    companion object {
+        private const val BASE_PATH = "bookmarks"
+        @JvmField
+        val BASE_URI: Uri = "content://${BuildConfig.BOOKMARK_AUTHORITY}/$BASE_PATH".toUri()
+
+        @JvmStatic
+        fun uriForName(name: String): Uri = "$BASE_URI/$name".toUri()
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java
deleted file mode 100644
index 3274809e9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package fr.free.nrw.commons.bookmarks.pictures;
-
-import org.apache.commons.lang3.StringUtils;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.bookmarks.Bookmark;
-import fr.free.nrw.commons.media.MediaClient;
-import io.reactivex.Observable;
-import io.reactivex.ObservableSource;
-import io.reactivex.Single;
-import io.reactivex.functions.Function;
-
-@Singleton
-public class BookmarkPicturesController {
-
-    private final MediaClient mediaClient;
-    private final BookmarkPicturesDao bookmarkDao;
-
-    private List currentBookmarks;
-
-    @Inject
-    public BookmarkPicturesController(MediaClient mediaClient, BookmarkPicturesDao bookmarkDao) {
-        this.mediaClient = mediaClient;
-        this.bookmarkDao = bookmarkDao;
-        currentBookmarks = new ArrayList<>();
-    }
-
-    /**
-     * Loads the Media objects from the raw data stored in DB and the API.
-     * @return a list of bookmarked Media object
-     */
-    Single> loadBookmarkedPictures() {
-        List bookmarks = bookmarkDao.getAllBookmarks();
-        currentBookmarks = bookmarks;
-        return Observable.fromIterable(bookmarks)
-                .flatMap((Function>) this::getMediaFromBookmark)
-                .filter(media -> media != null && !StringUtils.isBlank(media.getFilename()))
-                .toList();
-    }
-
-    private Observable getMediaFromBookmark(Bookmark bookmark) {
-        Media dummyMedia = new Media("");
-        return mediaClient.getMedia(bookmark.getMediaName())
-                .map(media -> media == null ? dummyMedia : media)
-                .onErrorReturn(throwable -> dummyMedia)
-                .toObservable();
-    }
-
-    /**
-     * Loads the Media objects from the raw data stored in DB and the API.
-     * @return a list of bookmarked Media object
-     */
-    boolean needRefreshBookmarkedPictures() {
-        List bookmarks = bookmarkDao.getAllBookmarks();
-        return bookmarks.size() != currentBookmarks.size();
-    }
-
-    /**
-     * Cancels the requests to the API and the DB
-     */
-    void stop() {
-        //noop
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt
new file mode 100644
index 000000000..5ee88d973
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt
@@ -0,0 +1,38 @@
+package fr.free.nrw.commons.bookmarks.pictures
+
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.bookmarks.models.Bookmark
+import fr.free.nrw.commons.media.MediaClient
+import io.reactivex.Observable
+import io.reactivex.Single
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BookmarkPicturesController @Inject constructor(
+    private val mediaClient: MediaClient,
+    private val bookmarkDao: BookmarkPicturesDao
+) {
+    private var currentBookmarks: List = listOf()
+
+    /**
+     * Loads the Media objects from the raw data stored in DB and the API.
+     * @return a list of bookmarked Media object
+     */
+    fun loadBookmarkedPictures(): Single> {
+        val bookmarks = bookmarkDao.getAllBookmarks()
+        currentBookmarks = bookmarks
+        return Observable.fromIterable(bookmarks).flatMap {
+            mediaClient.getMedia(it.mediaName)
+                .toObservable()
+                .onErrorResumeNext(Observable.empty())
+        }.toList()
+    }
+
+    fun needRefreshBookmarkedPictures(): Boolean {
+        val bookmarks = bookmarkDao.getAllBookmarks()
+        return bookmarks.size != currentBookmarks.size
+    }
+
+    fun stop() = Unit
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java
deleted file mode 100644
index 84bd1e6d2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java
+++ /dev/null
@@ -1,225 +0,0 @@
-package fr.free.nrw.commons.bookmarks.pictures;
-
-import android.content.ContentProviderClient;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.RemoteException;
-
-import androidx.annotation.NonNull;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Provider;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.bookmarks.Bookmark;
-
-import static fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.BASE_URI;
-
-@Singleton
-public class BookmarkPicturesDao {
-
-    private final Provider clientProvider;
-
-    @Inject
-    public BookmarkPicturesDao(@Named("bookmarks") Provider clientProvider) {
-        this.clientProvider = clientProvider;
-    }
-
-
-    /**
-     * Find all persisted pictures bookmarks on database
-     *
-     * @return list of bookmarks
-     */
-    @NonNull
-    public List getAllBookmarks() {
-        List items = new ArrayList<>();
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    BookmarkPicturesContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    null,
-                    new String[]{},
-                    null);
-            while (cursor != null && cursor.moveToNext()) {
-                items.add(fromCursor(cursor));
-            }
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return items;
-    }
-
-
-    /**
-     * Look for a bookmark in database and in order to insert or delete it
-     *
-     * @param bookmark : Bookmark object
-     * @return boolean : is bookmark now fav ?
-     */
-    public boolean updateBookmark(Bookmark bookmark) {
-        boolean bookmarkExists = findBookmark(bookmark);
-        if (bookmarkExists) {
-            deleteBookmark(bookmark);
-        } else {
-            addBookmark(bookmark);
-        }
-        return !bookmarkExists;
-    }
-
-    /**
-     * Add a Bookmark to database
-     *
-     * @param bookmark : Bookmark to add
-     */
-    private void addBookmark(Bookmark bookmark) {
-        ContentProviderClient db = clientProvider.get();
-        try {
-            db.insert(BASE_URI, toContentValues(bookmark));
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            db.release();
-        }
-    }
-
-    /**
-     * Delete a bookmark from database
-     *
-     * @param bookmark : Bookmark to delete
-     */
-    private void deleteBookmark(Bookmark bookmark) {
-        ContentProviderClient db = clientProvider.get();
-        try {
-            if (bookmark.getContentUri() == null) {
-                throw new RuntimeException("tried to delete item with no content URI");
-            } else {
-                db.delete(bookmark.getContentUri(), null, null);
-            }
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            db.release();
-        }
-    }
-
-    /**
-     * Find a bookmark from database based on its name
-     *
-     * @param bookmark : Bookmark to find
-     * @return boolean : is bookmark in database ?
-     */
-    public boolean findBookmark(Bookmark bookmark) {
-        if (bookmark == null) {//Avoiding NPE's
-            return false;
-        }
-
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    BookmarkPicturesContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    Table.COLUMN_MEDIA_NAME + "=?",
-                    new String[]{bookmark.getMediaName()},
-                    null);
-            if (cursor != null && cursor.moveToFirst()) {
-                return true;
-            }
-        } catch (RemoteException e) {
-            // This feels lazy, but to hell with checked exceptions. :)
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return false;
-    }
-
-    @NonNull
-    Bookmark fromCursor(Cursor cursor) {
-        String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME));
-        return new Bookmark(
-                fileName,
-                cursor.getString(cursor.getColumnIndex(Table.COLUMN_CREATOR)),
-                BookmarkPicturesContentProvider.uriForName(fileName)
-        );
-    }
-
-    private ContentValues toContentValues(Bookmark bookmark) {
-        ContentValues cv = new ContentValues();
-        cv.put(BookmarkPicturesDao.Table.COLUMN_MEDIA_NAME, bookmark.getMediaName());
-        cv.put(BookmarkPicturesDao.Table.COLUMN_CREATOR, bookmark.getMediaCreator());
-        return cv;
-    }
-
-
-    public static class Table {
-        public static final String TABLE_NAME = "bookmarks";
-
-        public static final String COLUMN_MEDIA_NAME = "media_name";
-        public static final String COLUMN_CREATOR = "media_creator";
-
-        // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
-        public static final String[] ALL_FIELDS = {
-                COLUMN_MEDIA_NAME,
-                COLUMN_CREATOR
-        };
-
-        public static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
-
-        public static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
-                + COLUMN_MEDIA_NAME + " STRING PRIMARY KEY,"
-                + COLUMN_CREATOR + " STRING"
-                + ");";
-
-        public static void onCreate(SQLiteDatabase db) {
-            db.execSQL(CREATE_TABLE_STATEMENT);
-        }
-
-        public static void onDelete(SQLiteDatabase db) {
-            db.execSQL(DROP_TABLE_STATEMENT);
-            onCreate(db);
-        }
-
-        public static void onUpdate(SQLiteDatabase db, int from, int to) {
-            if (from == to) {
-                return;
-            }
-            if (from < 7) {
-                // doesn't exist yet
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-
-            if (from == 7) {
-                // table added in version 8
-                onCreate(db);
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-
-            if (from == 8) {
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt
new file mode 100644
index 000000000..00c8e3228
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt
@@ -0,0 +1,144 @@
+package fr.free.nrw.commons.bookmarks.pictures
+
+import android.content.ContentProviderClient
+import android.content.ContentValues
+import android.database.Cursor
+import android.os.RemoteException
+import androidx.core.content.contentValuesOf
+import fr.free.nrw.commons.bookmarks.models.Bookmark
+import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.Companion.BASE_URI
+import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.Companion.uriForName
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.ALL_FIELDS
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_CREATOR
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_MEDIA_NAME
+import fr.free.nrw.commons.utils.getString
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Provider
+import javax.inject.Singleton
+
+@Singleton
+class BookmarkPicturesDao @Inject constructor(
+    @param:Named("bookmarks") private val clientProvider: Provider
+) {
+    /**
+     * Find all persisted pictures bookmarks on database
+     *
+     * @return list of bookmarks
+     */
+    fun getAllBookmarks(): List {
+        val items: MutableList = mutableListOf()
+        var cursor: Cursor? = null
+        val db = clientProvider.get()
+        try {
+            cursor = db.query(
+                BASE_URI, ALL_FIELDS, null, arrayOf(), null
+            )
+            while (cursor != null && cursor.moveToNext()) {
+                items.add(fromCursor(cursor))
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            cursor?.close()
+            db.release()
+        }
+        return items
+    }
+
+    /**
+     * Look for a bookmark in database and in order to insert or delete it
+     *
+     * @param bookmark : Bookmark object
+     * @return boolean : is bookmark now fav ?
+     */
+    fun updateBookmark(bookmark: Bookmark): Boolean {
+        val bookmarkExists = findBookmark(bookmark)
+        if (bookmarkExists) {
+            deleteBookmark(bookmark)
+        } else {
+            addBookmark(bookmark)
+        }
+        return !bookmarkExists
+    }
+
+    /**
+     * Add a Bookmark to database
+     *
+     * @param bookmark : Bookmark to add
+     */
+    private fun addBookmark(bookmark: Bookmark) {
+        val db = clientProvider.get()
+        try {
+            db.insert(BASE_URI, toContentValues(bookmark))
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+    }
+
+    /**
+     * Delete a bookmark from database
+     *
+     * @param bookmark : Bookmark to delete
+     */
+    private fun deleteBookmark(bookmark: Bookmark) {
+        val db = clientProvider.get()
+        try {
+            if (bookmark.contentUri == null) {
+                throw RuntimeException("tried to delete item with no content URI")
+            } else {
+                db.delete(bookmark.contentUri!!, null, null)
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+    }
+
+    /**
+     * Find a bookmark from database based on its name
+     *
+     * @param bookmark : Bookmark to find
+     * @return boolean : is bookmark in database ?
+     */
+    fun findBookmark(bookmark: Bookmark?): Boolean {
+        if (bookmark == null) {
+            return false
+        }
+
+        var cursor: Cursor? = null
+        val db = clientProvider.get()
+        try {
+            cursor = db.query(
+                BASE_URI, ALL_FIELDS, "$COLUMN_MEDIA_NAME=?", arrayOf(bookmark.mediaName), null
+            )
+            if (cursor != null && cursor.moveToFirst()) {
+                return true
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            cursor?.close()
+            db.release()
+        }
+        return false
+    }
+
+    fun fromCursor(cursor: Cursor): Bookmark {
+        var fileName = cursor.getString(COLUMN_MEDIA_NAME)
+        if (fileName == null) {
+            fileName = ""
+        }
+        return Bookmark(
+            fileName, cursor.getString(COLUMN_CREATOR), uriForName(fileName)
+        )
+    }
+
+    private fun toContentValues(bookmark: Bookmark): ContentValues = contentValuesOf(
+        COLUMN_MEDIA_NAME to bookmark.mediaName,
+        COLUMN_CREATOR to bookmark.mediaCreator
+    )
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java
deleted file mode 100644
index d6a6a55fb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java
+++ /dev/null
@@ -1,230 +0,0 @@
-package fr.free.nrw.commons.bookmarks.pictures;
-
-import android.annotation.SuppressLint;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.GridView;
-import android.widget.ListAdapter;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import dagger.android.support.DaggerFragment;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.bookmarks.BookmarksActivity;
-import fr.free.nrw.commons.category.GridViewAdapter;
-import fr.free.nrw.commons.utils.NetworkUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
-
-public class BookmarkPicturesFragment extends DaggerFragment {
-
-    private static final int TIMEOUT_SECONDS = 15;
-
-    private GridViewAdapter gridAdapter;
-    private CompositeDisposable compositeDisposable = new CompositeDisposable();
-
-    @BindView(R.id.statusMessage) TextView statusTextView;
-    @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
-    @BindView(R.id.bookmarkedPicturesList) GridView gridView;
-    @BindView(R.id.parentLayout) RelativeLayout parentLayout;
-
-    @Inject
-    BookmarkPicturesController controller;
-
-    /**
-     * Create an instance of the fragment with the right bundle parameters
-     * @return an instance of the fragment
-     */
-    public static BookmarkPicturesFragment newInstance() {
-        return new BookmarkPicturesFragment();
-    }
-
-    @Override
-    public View onCreateView(
-            @NonNull LayoutInflater inflater,
-            ViewGroup container,
-            Bundle savedInstanceState
-    ) {
-        View v = inflater.inflate(R.layout.fragment_bookmarks_pictures, container, false);
-        ButterKnife.bind(this, v);
-        return v;
-    }
-
-    @Override
-    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-        gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity());
-        initList();
-    }
-
-    @Override
-    public void onStop() {
-        super.onStop();
-        controller.stop();
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        compositeDisposable.clear();
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-        if (controller.needRefreshBookmarkedPictures()) {
-            gridView.setVisibility(GONE);
-            if (gridAdapter != null) {
-                gridAdapter.clear();
-                ((BookmarksActivity) getContext()).viewPagerNotifyDataSetChanged();
-            }
-            initList();
-        }
-    }
-
-    /**
-     * Checks for internet connection and then initializes
-     * the recycler view with bookmarked pictures
-     */
-    @SuppressLint("CheckResult")
-    private void initList() {
-        if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
-            handleNoInternet();
-            return;
-        }
-
-        progressBar.setVisibility(VISIBLE);
-        statusTextView.setVisibility(GONE);
-
-        compositeDisposable.add(controller.loadBookmarkedPictures()
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
-                .subscribe(this::handleSuccess, this::handleError));
-    }
-
-    /**
-     * Handles the UI updates for no internet scenario
-     */
-    private void handleNoInternet() {
-        progressBar.setVisibility(GONE);
-        if (gridAdapter == null || gridAdapter.isEmpty()) {
-            statusTextView.setVisibility(VISIBLE);
-            statusTextView.setText(getString(R.string.no_internet));
-        } else {
-            ViewUtil.showShortSnackbar(parentLayout, R.string.no_internet);
-        }
-    }
-
-    /**
-     * Logs and handles API error scenario
-     * @param throwable
-     */
-    private void handleError(Throwable throwable) {
-        Timber.e(throwable, "Error occurred while loading images inside a category");
-        try{
-            ViewUtil.showShortSnackbar(parentLayout, R.string.error_loading_images);
-            initErrorView();
-        }catch (Exception e){
-            e.printStackTrace();
-        }
-    }
-
-    /**
-     * Handles the UI updates for a error scenario
-     */
-    private void initErrorView() {
-        progressBar.setVisibility(GONE);
-        if (gridAdapter == null || gridAdapter.isEmpty()) {
-            statusTextView.setVisibility(VISIBLE);
-            statusTextView.setText(getString(R.string.no_images_found));
-        } else {
-            statusTextView.setVisibility(GONE);
-        }
-    }
-
-    /**
-     * Handles the UI updates when there is no bookmarks
-     */
-    private void initEmptyBookmarkListView() {
-        progressBar.setVisibility(GONE);
-        if (gridAdapter == null || gridAdapter.isEmpty()) {
-            statusTextView.setVisibility(VISIBLE);
-            statusTextView.setText(getString(R.string.bookmark_empty));
-        } else {
-            statusTextView.setVisibility(GONE);
-        }
-    }
-
-    /**
-     * Handles the success scenario
-     * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter
-     * @param collection List of new Media to be displayed
-     */
-    private void handleSuccess(List collection) {
-        if (collection == null) {
-            initErrorView();
-            return;
-        }
-        if (collection.isEmpty()) {
-            initEmptyBookmarkListView();
-            return;
-        }
-
-        if (gridAdapter == null) {
-            setAdapter(collection);
-        } else {
-            if (gridAdapter.containsAll(collection)) {
-                return;
-            }
-            gridAdapter.addItems(collection);
-            ((BookmarksActivity) getContext()).viewPagerNotifyDataSetChanged();
-        }
-        progressBar.setVisibility(GONE);
-        statusTextView.setVisibility(GONE);
-        gridView.setVisibility(VISIBLE);
-    }
-
-    /**
-     * Initializes the adapter with a list of Media objects
-     * @param mediaList List of new Media to be displayed
-     */
-    private void setAdapter(List mediaList) {
-        gridAdapter = new GridViewAdapter(
-                this.getContext(),
-                R.layout.layout_category_images,
-                mediaList
-        );
-        gridView.setAdapter(gridAdapter);
-    }
-
-    /**
-     * It return an instance of gridView adapter which helps in extracting media details
-     * used by the gridView
-     * @return  GridView Adapter
-     */
-    public ListAdapter getAdapter() {
-        return gridView.getAdapter();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt
new file mode 100644
index 000000000..e8c61371a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt
@@ -0,0 +1,201 @@
+package fr.free.nrw.commons.bookmarks.pictures
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView.OnItemClickListener
+import android.widget.ListAdapter
+import dagger.android.support.DaggerFragment
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment
+import fr.free.nrw.commons.category.GridViewAdapter
+import fr.free.nrw.commons.databinding.FragmentBookmarksPicturesBinding
+import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished
+import fr.free.nrw.commons.utils.ViewUtil.showShortSnackbar
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.functions.Consumer
+import io.reactivex.schedulers.Schedulers
+import timber.log.Timber
+import javax.inject.Inject
+
+class BookmarkPicturesFragment : DaggerFragment() {
+    private var gridAdapter: GridViewAdapter? = null
+    private val compositeDisposable = CompositeDisposable()
+
+    private var binding: FragmentBookmarksPicturesBinding? = null
+
+    @JvmField
+    @Inject
+    var controller: BookmarkPicturesController? = null
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        binding = FragmentBookmarksPicturesBinding.inflate(inflater, container, false)
+        return binding!!.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        binding!!.bookmarkedPicturesList.onItemClickListener =
+            parentFragment as OnItemClickListener?
+        initList()
+    }
+
+    override fun onStop() {
+        super.onStop()
+        controller!!.stop()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        compositeDisposable.clear()
+        binding = null
+    }
+
+    override fun onResume() {
+        super.onResume()
+        if (controller!!.needRefreshBookmarkedPictures()) {
+            binding!!.bookmarkedPicturesList.visibility = View.GONE
+            gridAdapter?.let {
+                it.clear()
+                (parentFragment as BookmarkListRootFragment).viewPagerNotifyDataSetChanged()
+            }
+            initList()
+        }
+    }
+
+    /**
+     * Checks for internet connection and then initializes
+     * the recycler view with bookmarked pictures
+     */
+    @SuppressLint("CheckResult")
+    private fun initList() {
+        if (!isInternetConnectionEstablished(context)) {
+            handleNoInternet()
+            return
+        }
+
+        binding!!.loadingImagesProgressBar.visibility = View.VISIBLE
+        binding!!.statusMessage.visibility = View.GONE
+
+        compositeDisposable.add(
+            controller!!.loadBookmarkedPictures()
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(::handleSuccess, ::handleError)
+        )
+    }
+
+    /**
+     * Handles the UI updates for no internet scenario
+     */
+    private fun handleNoInternet() {
+        binding!!.loadingImagesProgressBar.visibility = View.GONE
+        if (gridAdapter == null || gridAdapter!!.isEmpty) {
+            binding!!.statusMessage.visibility = View.VISIBLE
+            binding!!.statusMessage.text = getString(R.string.no_internet)
+        } else {
+            showShortSnackbar(binding!!.parentLayout, R.string.no_internet)
+        }
+    }
+
+    /**
+     * Logs and handles API error scenario
+     * @param throwable
+     */
+    private fun handleError(throwable: Throwable) {
+        Timber.e(throwable, "Error occurred while loading images inside a category")
+        try {
+            showShortSnackbar(binding!!.root, R.string.error_loading_images)
+            initErrorView()
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+    /**
+     * Handles the UI updates for a error scenario
+     */
+    private fun initErrorView() {
+        binding!!.loadingImagesProgressBar.visibility = View.GONE
+        if (gridAdapter == null || gridAdapter!!.isEmpty) {
+            binding!!.statusMessage.visibility = View.VISIBLE
+            binding!!.statusMessage.text = getString(R.string.no_images_found)
+        } else {
+            binding!!.statusMessage.visibility = View.GONE
+        }
+    }
+
+    /**
+     * Handles the UI updates when there is no bookmarks
+     */
+    private fun initEmptyBookmarkListView() {
+        binding!!.loadingImagesProgressBar.visibility = View.GONE
+        if (gridAdapter == null || gridAdapter!!.isEmpty) {
+            binding!!.statusMessage.visibility = View.VISIBLE
+            binding!!.statusMessage.text = getString(R.string.bookmark_empty)
+        } else {
+            binding!!.statusMessage.visibility = View.GONE
+        }
+    }
+
+    /**
+     * Handles the success scenario
+     * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter
+     * @param collection List of new Media to be displayed
+     */
+    private fun handleSuccess(collection: List?) {
+        if (collection == null) {
+            initErrorView()
+            return
+        }
+        if (collection.isEmpty()) {
+            initEmptyBookmarkListView()
+            return
+        }
+
+        if (gridAdapter == null) {
+            setAdapter(collection)
+        } else {
+            if (gridAdapter!!.containsAll(collection)) {
+                binding!!.loadingImagesProgressBar.visibility = View.GONE
+                binding!!.statusMessage.visibility = View.GONE
+                binding!!.bookmarkedPicturesList.visibility = View.VISIBLE
+                binding!!.bookmarkedPicturesList.adapter = gridAdapter
+                return
+            }
+            gridAdapter!!.addItems(collection)
+            (parentFragment as BookmarkListRootFragment).viewPagerNotifyDataSetChanged()
+        }
+        binding!!.loadingImagesProgressBar.visibility = View.GONE
+        binding!!.statusMessage.visibility = View.GONE
+        binding!!.bookmarkedPicturesList.visibility = View.VISIBLE
+    }
+
+    /**
+     * Initializes the adapter with a list of Media objects
+     * @param mediaList List of new Media to be displayed
+     */
+    private fun setAdapter(mediaList: List) {
+        gridAdapter = GridViewAdapter(
+            requireContext(),
+            R.layout.layout_category_images,
+            mediaList.toMutableList()
+        )
+        binding?.let {  it.bookmarkedPicturesList.adapter = gridAdapter }
+    }
+
+    /**
+     * It return an instance of gridView adapter which helps in extracting media details
+     * used by the gridView
+     * @return  GridView Adapter
+     */
+    fun getAdapter(): ListAdapter? = binding?.bookmarkedPicturesList?.adapter
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt
new file mode 100644
index 000000000..6a8f4d541
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt
@@ -0,0 +1,54 @@
+package fr.free.nrw.commons.bookmarks.pictures
+
+import android.database.sqlite.SQLiteDatabase
+
+object BookmarksTable {
+    const val TABLE_NAME: String = "bookmarks"
+    const val COLUMN_MEDIA_NAME: String = "media_name"
+    const val COLUMN_CREATOR: String = "media_creator"
+
+    // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+    val ALL_FIELDS = arrayOf(
+        COLUMN_MEDIA_NAME,
+        COLUMN_CREATOR
+    )
+
+    const val DROP_TABLE_STATEMENT: String = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+    const val CREATE_TABLE_STATEMENT: String = ("CREATE TABLE $TABLE_NAME (" +
+            "$COLUMN_MEDIA_NAME STRING PRIMARY KEY, " +
+            "$COLUMN_CREATOR STRING" +
+            ");")
+
+    fun onCreate(db: SQLiteDatabase) =
+        db.execSQL(CREATE_TABLE_STATEMENT)
+
+    fun onDelete(db: SQLiteDatabase) {
+        db.execSQL(DROP_TABLE_STATEMENT)
+        onCreate(db)
+    }
+
+    fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+        if (from == to) {
+            return
+        }
+
+        if (from < 7) {
+            // doesn't exist yet
+            onUpdate(db, from+1, to)
+            return
+        }
+
+        if (from == 7) {
+            // table added in version 8
+            onCreate(db)
+            onUpdate(db, from+1, to)
+            return
+        }
+
+        if (from == 8) {
+            onUpdate(db, from+1, to)
+            return
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java b/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
deleted file mode 100644
index 8820101e8..000000000
--- a/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package fr.free.nrw.commons.caching;
-
-import com.github.varunpant.quadtree.Point;
-import com.github.varunpant.quadtree.QuadTree;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import timber.log.Timber;
-
-@Singleton
-public class CacheController {
-
-    private final QuadTree> quadTree;
-    private double x, y;
-    private double xMinus, xPlus, yMinus, yPlus;
-
-    private static final int EARTH_RADIUS = 6378137;
-
-    @Inject
-    public CacheController(QuadTree quadTree) {
-        this.quadTree = quadTree;
-    }
-
-    public void setQtPoint(double decLongitude, double decLatitude) {
-        x = decLongitude;
-        y = decLatitude;
-        Timber.d("New QuadTree created");
-        Timber.d("X (longitude) value: %f, Y (latitude) value: %f", x, y);
-    }
-
-    public List findCategory() {
-        Point>[] pointsFound;
-        //Convert decLatitude and decLongitude to a coordinate offset range
-        convertCoordRange();
-        pointsFound = quadTree.searchWithin(xMinus, yMinus, xPlus, yPlus);
-        List displayCatList = new ArrayList<>();
-        Timber.d("Points found in quadtree: %s", Arrays.toString(pointsFound));
-
-        if (pointsFound.length != 0) {
-            Timber.d("Entering for loop");
-
-            for (Point> point : pointsFound) {
-                Timber.d("Nearby point: %s", point);
-                displayCatList = point.getValue();
-                Timber.d("Nearby cat: %s", point.getValue());
-            }
-
-            Timber.d("Categories found in cache: %s", displayCatList);
-        } else {
-            Timber.d("No categories found in cache");
-        }
-        return displayCatList;
-    }
-
-    //Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters
-    private void convertCoordRange() {
-        //Position, decimal degrees
-        double lat = y;
-        double lon = x;
-
-        //offsets in meters
-        double offset = 100;
-
-        //Coordinate offsets in radians
-        double dLat = offset / EARTH_RADIUS;
-        double dLon = offset / (EARTH_RADIUS * Math.cos(Math.PI * lat / 180));
-
-        //OffsetPosition, decimal degrees
-        yPlus  = lat + dLat * 180 / Math.PI;
-        yMinus = lat - dLat * 180 / Math.PI;
-        xPlus  = lon + dLon * 180 / Math.PI;
-        xMinus = lon - dLon * 180 / Math.PI;
-        Timber.d("Search within: xMinus=%s, yMinus=%s, xPlus=%s, yPlus=%s",
-                xMinus, yMinus, xPlus, yPlus);
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/Campaign.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/Campaign.kt
deleted file mode 100644
index bab54fbc1..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/Campaign.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package fr.free.nrw.commons.campaigns
-
-/**
- * A data class to hold a campaign
- */
-data class Campaign(var title: String? = null,
-                    var description: String? = null,
-                    var startDate: String? = null,
-                    var endDate: String? = null,
-                    var link: String? = null)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
index dd82e181d..9f94e8592 100644
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
@@ -7,7 +7,8 @@ import com.google.gson.annotations.SerializedName
  */
 class CampaignConfig {
     @SerializedName("showOnlyLiveCampaigns")
-    private val showOnlyLiveCampaigns = false
+    var showOnlyLiveCampaigns = false
+
     @SerializedName("sortBy")
-    private val sortBy: String? = null
+    var sortBy: String? = null
 }
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
index f57d1b98f..1656109e7 100644
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
@@ -1,14 +1,15 @@
 package fr.free.nrw.commons.campaigns
 
 import com.google.gson.annotations.SerializedName
+import fr.free.nrw.commons.campaigns.models.Campaign
 
 /**
  * Data class to hold the response from the campaigns api
  */
 class CampaignResponseDTO {
     @SerializedName("config")
-    val campaignConfig: CampaignConfig? = null
-    @SerializedName("campaigns")
-    val campaigns: List? = null
+    var campaignConfig: CampaignConfig? = null
 
+    @SerializedName("campaigns")
+    var campaigns: List? = null
 }
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java
deleted file mode 100644
index fed6617b4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package fr.free.nrw.commons.campaigns;
-
-import android.content.Context;
-import android.net.Uri;
-import android.util.AttributeSet;
-import android.view.View;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.wikipedia.util.DateUtil;
-
-import java.text.ParseException;
-import java.util.Date;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.contributions.MainActivity;
-import fr.free.nrw.commons.utils.CommonsDateUtil;
-import fr.free.nrw.commons.utils.SwipableCardView;
-import fr.free.nrw.commons.utils.ViewUtil;
-
-/**
- * A view which represents a single campaign
- */
-public class CampaignView extends SwipableCardView {
-    Campaign campaign = null;
-    private ViewHolder viewHolder;
-
-    public CampaignView(@NonNull Context context) {
-        super(context);
-        init();
-    }
-
-    public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs) {
-        super(context, attrs);
-        init();
-    }
-
-    public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
-        super(context, attrs, defStyleAttr);
-        init();
-    }
-
-    public void setCampaign(Campaign campaign) {
-        this.campaign = campaign;
-        if (campaign != null) {
-            this.setVisibility(View.VISIBLE);
-            viewHolder.init();
-        } else {
-            this.setVisibility(View.GONE);
-        }
-    }
-
-    @Override public boolean onSwipe(View view) {
-        view.setVisibility(View.GONE);
-        ((MainActivity) getContext()).defaultKvStore
-                .putBoolean("displayCampaignsCardView", false);
-        ViewUtil.showLongToast(getContext(),
-            getResources().getString(R.string.nearby_campaign_dismiss_message));
-        return true;
-    }
-
-    private void init() {
-        View rootView = inflate(getContext(), R.layout.layout_campagin, this);
-        viewHolder = new ViewHolder(rootView);
-        setOnClickListener(view -> {
-            if (campaign != null) {
-                Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink()));
-            }
-        });
-    }
-
-    public class ViewHolder {
-
-        @BindView(R.id.tv_title) TextView tvTitle;
-        @BindView(R.id.tv_description) TextView tvDescription;
-        @BindView(R.id.tv_dates) TextView tvDates;
-
-        public ViewHolder(View itemView) {
-            ButterKnife.bind(this, itemView);
-        }
-
-        public void init() {
-            if (campaign != null) {
-                tvTitle.setText(campaign.getTitle());
-                tvDescription.setText(campaign.getDescription());
-                try {
-                    Date startDate = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate());
-                    Date endDate = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getEndDate());
-                    tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
-                            DateUtil.getExtraShortDateString(endDate)));
-                } catch (ParseException e) {
-                    e.printStackTrace();
-                }
-            }
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
new file mode 100644
index 000000000..7a30ff5c4
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
@@ -0,0 +1,121 @@
+package fr.free.nrw.commons.campaigns
+
+import android.content.Context
+import android.net.Uri
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import androidx.core.content.ContextCompat
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.campaigns.models.Campaign
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.LayoutCampaginBinding
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
+import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString
+import fr.free.nrw.commons.utils.SwipableCardView
+import fr.free.nrw.commons.utils.ViewUtil.showLongToast
+import fr.free.nrw.commons.utils.handleWebUrl
+import timber.log.Timber
+import java.text.ParseException
+
+/**
+ * A view which represents a single campaign
+ */
+class CampaignView : SwipableCardView {
+    private var campaign: Campaign? = null
+    private var binding: LayoutCampaginBinding? = null
+    private var viewHolder: ViewHolder? = null
+    private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE
+
+    constructor(context: Context) : super(context) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context, attrs, defStyleAttr) {
+        init()
+    }
+
+    fun setCampaign(campaign: Campaign?) {
+        this.campaign = campaign
+        if (campaign != null) {
+            if (campaign.isWLMCampaign) {
+                campaignPreference = WLM_CARD_PREFERENCE
+            }
+            visibility = VISIBLE
+            viewHolder!!.init()
+        } else {
+            visibility = GONE
+        }
+    }
+
+    override fun onSwipe(view: View): Boolean {
+        view.visibility = GONE
+        (context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false)
+        showLongToast(
+            context,
+            resources.getString(R.string.nearby_campaign_dismiss_message)
+        )
+        return true
+    }
+
+    private fun init() {
+        binding = LayoutCampaginBinding.inflate(
+            LayoutInflater.from(context), this, true
+        )
+        viewHolder = ViewHolder()
+        setOnClickListener {
+            campaign?.let {
+                if (it.isWLMCampaign) {
+                    ((context) as MainActivity).showNearby()
+                } else {
+                    handleWebUrl(context, Uri.parse(it.link))
+                }
+            }
+        }
+    }
+
+    inner class ViewHolder {
+        fun init() {
+            if (campaign != null) {
+                binding!!.ivCampaign.setImageDrawable(
+                    ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign)
+                )
+                binding!!.tvTitle.text = campaign!!.title
+                binding!!.tvDescription.text = campaign!!.description
+                try {
+                    if (campaign!!.isWLMCampaign) {
+                        binding!!.tvDates.text = String.format(
+                            "%1s - %2s", campaign!!.startDate,
+                            campaign!!.endDate
+                        )
+                    } else {
+                        val startDate = getIso8601DateFormatShort().parse(
+                            campaign?.startDate
+                        )
+                        val endDate = getIso8601DateFormatShort().parse(
+                            campaign?.endDate
+                        )
+                        binding!!.tvDates.text = String.format(
+                            "%1s - %2s", getExtraShortDateString(
+                                startDate!!
+                            ), getExtraShortDateString(endDate!!)
+                        )
+                    }
+                } catch (e: ParseException) {
+                    Timber.e(e)
+                }
+            }
+        }
+    }
+
+    companion object {
+        const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView"
+        const val WLM_CARD_PREFERENCE: String = "displayWLMCardView"
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java
deleted file mode 100644
index 255a10a98..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java
+++ /dev/null
@@ -1,122 +0,0 @@
-package fr.free.nrw.commons.campaigns;
-
-import android.annotation.SuppressLint;
-
-import java.text.ParseException;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.BasePresenter;
-import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
-import fr.free.nrw.commons.utils.CommonsDateUtil;
-import io.reactivex.Scheduler;
-import io.reactivex.Single;
-import io.reactivex.SingleObserver;
-import io.reactivex.disposables.Disposable;
-import timber.log.Timber;
-
-import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
-import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
-
-/**
- * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
- * success and error
- */
-@Singleton
-public class CampaignsPresenter implements BasePresenter {
-    private final OkHttpJsonApiClient okHttpJsonApiClient;
-    private final Scheduler mainThreadScheduler;
-    private final Scheduler ioScheduler;
-
-    private ICampaignsView view;
-    private Disposable disposable;
-    private Campaign campaign;
-
-    @Inject
-    public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) {
-        this.okHttpJsonApiClient = okHttpJsonApiClient;
-        this.mainThreadScheduler=mainThreadScheduler;
-        this.ioScheduler=ioScheduler;
-    }
-
-    @Override
-    public void onAttachView(ICampaignsView view) {
-        this.view = view;
-    }
-
-    @Override public void onDetachView() {
-        this.view = null;
-        if (disposable != null) {
-            disposable.dispose();
-        }
-    }
-
-    /**
-     * make the api call to fetch the campaigns
-     */
-    @SuppressLint("CheckResult")
-    public void getCampaigns() {
-        if (view != null && okHttpJsonApiClient != null) {
-            //If we already have a campaign, lets not make another call
-            if (this.campaign != null) {
-                view.showCampaigns(campaign);
-                return;
-            }
-            Single campaigns = okHttpJsonApiClient.getCampaigns();
-            campaigns.observeOn(mainThreadScheduler)
-                .subscribeOn(ioScheduler)
-                .subscribeWith(new SingleObserver() {
-
-                    @Override public void onSubscribe(Disposable d) {
-                        disposable = d;
-                    }
-
-                    @Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) {
-                        List campaigns = campaignResponseDTO.getCampaigns();
-                        if (campaigns == null || campaigns.isEmpty()) {
-                            Timber.e("The campaigns list is empty");
-                            view.showCampaigns(null);
-                            return;
-                        }
-                        Collections.sort(campaigns, (campaign, t1) -> {
-                            Date date1, date2;
-                            try {
-
-                                date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate());
-                                date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate());
-                            } catch (ParseException e) {
-                                e.printStackTrace();
-                                return -1;
-                            }
-                            return date1.compareTo(date2);
-                        });
-                        Date campaignEndDate, campaignStartDate;
-                        Date currentDate = new Date();
-                        try {
-                            for (Campaign aCampaign : campaigns) {
-                                campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate());
-                                campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate());
-                                if (campaignEndDate.compareTo(currentDate) >= 0
-                                    && campaignStartDate.compareTo(currentDate) <= 0) {
-                                    campaign = aCampaign;
-                                    break;
-                                }
-                            }
-                        } catch (ParseException e) {
-                            e.printStackTrace();
-                        }
-                        view.showCampaigns(campaign);
-                    }
-
-                    @Override public void onError(Throwable e) {
-                        Timber.e(e, "could not fetch campaigns");
-                    }
-                });
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt
new file mode 100644
index 000000000..53013c1ae
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt
@@ -0,0 +1,106 @@
+package fr.free.nrw.commons.campaigns
+
+import android.annotation.SuppressLint
+import fr.free.nrw.commons.BasePresenter
+import fr.free.nrw.commons.campaigns.models.Campaign
+import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
+import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD
+import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
+import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
+import io.reactivex.Scheduler
+import io.reactivex.disposables.Disposable
+import timber.log.Timber
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Date
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
+ * success and error
+ */
+@Singleton
+class CampaignsPresenter @Inject constructor(
+    private val okHttpJsonApiClient: OkHttpJsonApiClient?,
+    @param:Named(IO_THREAD) private val ioScheduler: Scheduler,
+    @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler
+) : BasePresenter {
+    private var view: ICampaignsView? = null
+    private var disposable: Disposable? = null
+    private var campaign: Campaign? = null
+
+    override fun onAttachView(view: ICampaignsView) {
+        this.view = view
+    }
+
+    override fun onDetachView() {
+        view = null
+        disposable?.dispose()
+    }
+
+    /**
+     * make the api call to fetch the campaigns
+     */
+    @SuppressLint("CheckResult")
+    fun getCampaigns() {
+        if (view != null && okHttpJsonApiClient != null) {
+            //If we already have a campaign, lets not make another call
+            if (campaign != null) {
+                view!!.showCampaigns(campaign)
+                return
+            }
+
+            okHttpJsonApiClient.getCampaigns()
+                .observeOn(mainThreadScheduler)
+                .subscribeOn(ioScheduler)
+                .doOnSubscribe { disposable = it }
+                .subscribe({ campaignResponseDTO ->
+                    val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
+                    if (campaigns.isNullOrEmpty()) {
+                        Timber.e("The campaigns list is empty")
+                        view!!.showCampaigns(null)
+                    } else {
+                        sortCampaignsByStartDate(campaigns)
+                        campaign = findActiveCampaign(campaigns)
+                        view!!.showCampaigns(campaign)
+                    }
+                }, {
+                    Timber.e(it, "could not fetch campaigns")
+                })
+        }
+    }
+
+    private fun sortCampaignsByStartDate(campaigns: MutableList) {
+        val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
+        campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign ->
+            val date1: Date?
+            val date2: Date?
+            try {
+                date1 = campaign.startDate?.let { dateFormat.parse(it) }
+                date2 = other.startDate?.let { dateFormat.parse(it) }
+            } catch (e: ParseException) {
+                Timber.e(e)
+                return@Comparator -1
+            }
+            if (date1 != null && date2 != null) date1.compareTo(date2) else -1
+        })
+    }
+
+    private fun findActiveCampaign(campaigns: List) : Campaign? {
+        val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
+        val currentDate = Date()
+        return try {
+            campaigns.firstOrNull {
+                val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) }
+                val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) }
+                campaignStartDate != null && campaignEndDate != null &&
+                        campaignEndDate >= currentDate && campaignStartDate <= currentDate
+            }
+        } catch (e: ParseException) {
+            Timber.e(e, "could not find active campaign")
+            null
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java
deleted file mode 100644
index 6661dad66..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package fr.free.nrw.commons.campaigns;
-
-import fr.free.nrw.commons.MvpView;
-
-/**
- * Interface which defines the view contracts of the campaign view
- */
-public interface ICampaignsView extends MvpView {
-    void showCampaigns(Campaign campaign);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt
new file mode 100644
index 000000000..1cbf7da1f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt
@@ -0,0 +1,10 @@
+package fr.free.nrw.commons.campaigns
+
+import fr.free.nrw.commons.campaigns.models.Campaign
+
+/**
+ * Interface which defines the view contracts of the campaign view
+ */
+interface ICampaignsView {
+    fun showCampaigns(campaign: Campaign?)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt
new file mode 100644
index 000000000..cd68797e0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt
@@ -0,0 +1,13 @@
+package fr.free.nrw.commons.campaigns.models
+
+/**
+ * A data class to hold a campaign
+ */
+data class Campaign(
+    var title: String? = null,
+    var description: String? = null,
+    var startDate: String? = null,
+    var endDate: String? = null,
+    var link: String? = null,
+    var isWLMCampaign: Boolean = false,
+)
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java
deleted file mode 100644
index e2030ee9a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java
+++ /dev/null
@@ -1,260 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.text.TextUtils;
-
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.upload.GpsCategoryModel;
-import fr.free.nrw.commons.utils.StringSortingUtils;
-import io.reactivex.Observable;
-import timber.log.Timber;
-
-/**
- * The model class for categories in upload
- */
-public class CategoriesModel{
-    private static final int SEARCH_CATS_LIMIT = 25;
-
-    private final CategoryClient categoryClient;
-    private final CategoryDao categoryDao;
-    private final JsonKvStore directKvStore;
-
-    private HashMap> categoriesCache;
-    private List selectedCategories;
-
-    @Inject GpsCategoryModel gpsCategoryModel;
-    @Inject
-    public CategoriesModel(CategoryClient categoryClient,
-                           CategoryDao categoryDao,
-                           @Named("default_preferences") JsonKvStore directKvStore) {
-        this.categoryClient = categoryClient;
-        this.categoryDao = categoryDao;
-        this.directKvStore = directKvStore;
-        this.categoriesCache = new HashMap<>();
-        this.selectedCategories = new ArrayList<>();
-    }
-
-    /**
-     * Sorts CategoryItem by similarity
-     * @param filter
-     * @return
-     */
-    public Comparator sortBySimilarity(final String filter) {
-        Comparator stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter);
-        return (firstItem, secondItem) -> stringSimilarityComparator
-                .compare(firstItem.getName(), secondItem.getName());
-    }
-
-    /**
-     * Returns if the item contains an year
-     * @param item
-     * @return
-     */
-    public boolean containsYear(String item) {
-        //Check for current and previous year to exclude these categories from removal
-        Calendar now = Calendar.getInstance();
-        int year = now.get(Calendar.YEAR);
-        String yearInString = String.valueOf(year);
-
-        int prevYear = year - 1;
-        String prevYearInString = String.valueOf(prevYear);
-        Timber.d("Previous year: %s", prevYearInString);
-
-        //Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
-        //And that item does not equal the current year or previous year
-        //And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
-        //Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
-        return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString))
-                || item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)")
-                || (item.matches(".*0s.*") && !item.matches(".*(200|201)0s.*")));
-    }
-
-    /**
-     * Updates category count in category dao
-     * @param item
-     */
-    public void updateCategoryCount(CategoryItem item) {
-        Category category = categoryDao.find(item.getName());
-
-        // Newly used category...
-        if (category == null) {
-            category = new Category(null, item.getName(), new Date(), 0);
-        }
-
-        category.incTimesUsed();
-        categoryDao.save(category);
-    }
-
-    boolean cacheContainsKey(String term) {
-        return categoriesCache.containsKey(term);
-    }
-    //endregion
-
-    /**
-     * Regional category search
-     * @param term
-     * @param imageTitleList
-     * @return
-     */
-    public Observable searchAll(String term, List imageTitleList) {
-        //If query text is empty, show him category based on gps and title and recent searches
-        if (TextUtils.isEmpty(term)) {
-            Observable categoryItemObservable = gpsCategories()
-                    .concatWith(titleCategories(imageTitleList));
-            if (hasDirectCategories()) {
-                categoryItemObservable.concatWith(directCategories().concatWith(recentCategories()));
-            }
-            return categoryItemObservable;
-        }
-
-        //if user types in something that is in cache, return cached category
-        if (cacheContainsKey(term)) {
-            return Observable.fromIterable(getCachedCategories(term))
-                    .map(name -> new CategoryItem(name, false));
-        }
-
-        //otherwise, search API for matching categories
-        return categoryClient
-                .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
-                .map(name -> new CategoryItem(name, false));
-    }
-
-
-    /**
-     * Returns cached categories
-     * @param term
-     * @return
-     */
-    private ArrayList getCachedCategories(String term) {
-        return categoriesCache.get(term);
-    }
-
-    /**
-     * Returns if we have a category in DirectKV Store
-     * @return
-     */
-    private boolean hasDirectCategories() {
-        return !directKvStore.getString("Category", "").equals("");
-    }
-
-    /**
-     * Returns categories in DirectKVStore
-     * @return
-     */
-    private Observable directCategories() {
-        String directCategory = directKvStore.getString("Category", "");
-        List categoryList = new ArrayList<>();
-        Timber.d("Direct category found: " + directCategory);
-
-        if (!directCategory.equals("")) {
-            categoryList.add(directCategory);
-            Timber.d("DirectCat does not equal emptyString. Direct Cat list has " + categoryList);
-        }
-        return Observable.fromIterable(categoryList).map(name -> new CategoryItem(name, false));
-    }
-
-    /**
-     * Returns GPS categories
-     * @return
-     */
-    Observable gpsCategories() {
-        return Observable.fromIterable(gpsCategoryModel.getCategoryList())
-                .map(name -> new CategoryItem(name, false));
-    }
-
-    /**
-     * Returns title based categories
-     * @param titleList
-     * @return
-     */
-    private Observable titleCategories(List titleList) {
-        return Observable.fromIterable(titleList)
-                .concatMap(this::getTitleCategories);
-    }
-
-    /**
-     * Return category for single title
-     * @param title
-     * @return
-     */
-    private Observable getTitleCategories(String title) {
-        return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT)
-                .map(name -> new CategoryItem(name, false));
-    }
-
-    /**
-     * Returns recent categories
-     * @return
-     */
-    private Observable recentCategories() {
-        return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT))
-                .map(s -> new CategoryItem(s, false));
-    }
-
-    /**
-     * Handles category item selection
-     * @param item
-     */
-    public void onCategoryItemClicked(CategoryItem item) {
-        if (item.isSelected()) {
-            selectCategory(item);
-            updateCategoryCount(item);
-        } else {
-            unselectCategory(item);
-        }
-    }
-
-    /**
-     * Select's category
-     * @param item
-     */
-    public void selectCategory(CategoryItem item) {
-        selectedCategories.add(item);
-    }
-
-    /**
-     * Unselect Category
-     * @param item
-     */
-    public void unselectCategory(CategoryItem item) {
-        selectedCategories.remove(item);
-    }
-
-
-    /**
-     * Get Selected Categories
-     * @return
-     */
-    public List getSelectedCategories() {
-        return selectedCategories;
-    }
-
-    /**
-     * Get Categories String List
-     * @return
-     */
-    public List getCategoryStringList() {
-        List output = new ArrayList<>();
-        for (CategoryItem item : selectedCategories) {
-            output.add(item.getName());
-        }
-        return output;
-    }
-
-    /**
-     * Cleanup the existing in memory cache's
-     */
-    public void cleanUp() {
-        this.categoriesCache.clear();
-        this.selectedCategories.clear();
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
new file mode 100644
index 000000000..47147944c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
@@ -0,0 +1,332 @@
+package fr.free.nrw.commons.category
+
+import android.text.TextUtils
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.upload.GpsCategoryModel
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+import fr.free.nrw.commons.utils.StringSortingUtils
+import io.reactivex.Observable
+import io.reactivex.functions.Function4
+import timber.log.Timber
+import java.util.Calendar
+import java.util.Date
+import javax.inject.Inject
+
+/**
+ * The model class for categories in upload
+ */
+class CategoriesModel
+    @Inject
+    constructor(
+        private val categoryClient: CategoryClient,
+        private val categoryDao: CategoryDao,
+        private val gpsCategoryModel: GpsCategoryModel,
+    ) {
+        private val selectedCategories: MutableList = mutableListOf()
+
+        /**
+         * Existing categories which are selected
+         */
+        private var selectedExistingCategories: MutableList = mutableListOf()
+
+        /**
+         * Returns true if an item is considered to be a spammy category which should be ignored
+         *
+         * @param item a category item that needs to be validated to know if it is spammy or not
+         * @return
+         */
+        fun isSpammyCategory(item: String): Boolean {
+
+            // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
+            val spammyCategory = item.matches("(.*)needing(.*)".toRegex())
+                    || item.matches("(.*)taken on(.*)".toRegex())
+
+            // checks for
+            // dd/mm/yyyy or yy
+            // yyyy or yy/mm/dd
+            // yyyy or yy/mm
+            // mm/yyyy or yy
+            // for `yy` it is assumed that 20XX is implicit.
+            // with separators [., /, -]
+            val isIrrelevantCategory =
+                item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex())
+
+
+            if (spammyCategory) {
+                return true
+            }
+
+            if(isIrrelevantCategory){
+                return true
+            }
+
+            val hasYear = item.matches("(.*\\d{4}.*)".toRegex())
+            val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex())
+
+            // finally if there's 4 digits year exists in XXXX it should only be in 20XX range.
+            return  hasYear && !validYearsRange
+        }
+
+        /**
+         * Updates category count in category dao
+         * @param item
+         */
+        fun updateCategoryCount(item: CategoryItem) {
+            var category = categoryDao.find(item.name)
+
+            // Newly used category...
+            if (category == null) {
+                category = Category(
+                    null, item.name,
+                    item.description,
+                    item.thumbnail,
+                    Date(),
+                    0
+                )
+            }
+            category.incTimesUsed()
+            categoryDao.save(category)
+        }
+
+        /**
+         * Regional category search
+         * @param term
+         * @param imageTitleList
+         * @return
+         */
+        fun searchAll(
+            term: String,
+            imageTitleList: List,
+            selectedDepictions: List,
+        ): Observable> =
+            suggestionsOrSearch(term, imageTitleList, selectedDepictions)
+                .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } }
+
+        private fun suggestionsOrSearch(
+            term: String,
+            imageTitleList: List,
+            selectedDepictions: List,
+        ): Observable> =
+            if (TextUtils.isEmpty(term)) {
+                Observable.combineLatest(
+                    categoriesFromDepiction(selectedDepictions),
+                    gpsCategoryModel.categoriesFromLocation,
+                    titleCategories(imageTitleList),
+                    Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)),
+                    Function4(::combine),
+                )
+            } else {
+                categoryClient
+                    .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
+                    .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
+                    .toObservable()
+            }
+
+        /**
+         * Fetches details of every category associated with selected depictions, converts them into
+         * CategoryItem and returns them in a list.
+         * If a selected depiction has no categories, the categories in which its P18 belongs are
+         * returned in the list.
+         *
+         * @param selectedDepictions selected DepictItems
+         * @return List of CategoryItem associated with selected depictions
+         */
+        private fun categoriesFromDepiction(selectedDepictions: List): Observable>? {
+            val observables =  selectedDepictions.map { depictedItem ->
+                if (depictedItem.commonsCategories.isEmpty()) {
+                    if (depictedItem.primaryImage == null) {
+                        return@map Observable.just(emptyList())
+                    }
+                    Observable.just(
+                            depictedItem.primaryImage
+                        ).map { image ->
+                            categoryClient
+                                .getCategoriesOfImage(
+                                    image,
+                                    SEARCH_CATS_LIMIT,
+                                ).map {
+                                    it.map { category ->
+                                        CategoryItem(
+                                            category.name,
+                                            category.description,
+                                            category.thumbnail,
+                                            category.isSelected,
+                                        )
+                                    }
+                                }.blockingGet()
+                        }.flatMapIterable { it }.toList()
+                        .toObservable()
+                } else {
+                    Observable
+                        .fromIterable(
+                            depictedItem.commonsCategories,
+                        ).map { categoryItem ->
+                            categoryClient
+                                .getCategoriesByName(
+                                    categoryItem.name,
+                                    categoryItem.name,
+                                    SEARCH_CATS_LIMIT,
+                                ).map {
+                                    CategoryItem(
+                                        it[0].name,
+                                        it[0].description,
+                                        it[0].thumbnail,
+                                        it[0].isSelected,
+                                    )
+                                }.blockingGet()
+                        }.toList()
+                        .toObservable()
+                }
+            }
+            return Observable.concat(observables)
+                .scan(mutableListOf()) { accumulator, currentList ->
+                    accumulator.apply { addAll(currentList) }
+                }
+        }
+
+        /**
+         * Fetches details of every category by their name, converts them into
+         * CategoryItem and returns them in a list.
+         *
+         * @param categoryNames selected Categories
+         * @return List of CategoryItem
+         */
+        fun getCategoriesByName(categoryNames: List): Observable>? =
+            Observable
+                .fromIterable(categoryNames)
+                .map { categoryName ->
+                    buildCategories(categoryName)
+                }.filter { categoryItem ->
+                    categoryItem.name != "Hidden"
+                }.toList()
+                .toObservable()
+
+        /**
+         * Fetches the categories and converts them into CategoryItem
+         */
+        fun buildCategories(categoryName: String): CategoryItem =
+            categoryClient
+                .getCategoriesByName(
+                    categoryName,
+                    categoryName,
+                    SEARCH_CATS_LIMIT,
+                ).map {
+                    if (it.isNotEmpty()) {
+                        CategoryItem(
+                            it[0].name,
+                            it[0].description,
+                            it[0].thumbnail,
+                            it[0].isSelected,
+                        )
+                    } else {
+                        CategoryItem(
+                            "Hidden",
+                            "Hidden",
+                            "hidden",
+                            false,
+                        )
+                    }
+                }.blockingGet()
+
+        private fun combine(
+            depictionCategories: List,
+            locationCategories: List,
+            titles: List,
+            recents: List,
+        ) = depictionCategories + locationCategories + titles + recents
+
+        /**
+         * Returns title based categories
+         * @param titleList
+         * @return
+         */
+        private fun titleCategories(titleList: List) =
+            if (titleList.isNotEmpty()) {
+                Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
+                    searchResults.map { it as List }.flatten()
+                }
+            } else {
+                Observable.just(emptyList())
+            }
+
+        /**
+         * Return category for single title
+         * @param title
+         * @return
+         */
+        private fun getTitleCategories(title: String): Observable> =
+            categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable()
+
+        /**
+         * Handles category item selection
+         * @param item
+         */
+        fun onCategoryItemClicked(
+            item: CategoryItem,
+            media: Media?,
+        ) {
+            if (media == null) {
+                if (item.isSelected) {
+                    selectedCategories.add(item)
+                    updateCategoryCount(item)
+                } else {
+                    selectedCategories.remove(item)
+                }
+            } else {
+                if (item.isSelected) {
+                    if (media.categories?.contains(item.name) == true) {
+                        selectedExistingCategories.add(item.name)
+                    } else {
+                        selectedCategories.add(item)
+                        updateCategoryCount(item)
+                    }
+                } else {
+                    if (media.categories?.contains(item.name) == true) {
+                        selectedExistingCategories.remove(item.name)
+                        if (!media.categories?.contains(item.name)!!) {
+                            val categoriesList: MutableList = ArrayList()
+                            categoriesList.add(item.name)
+                            categoriesList.addAll(media.categories!!)
+                            media.categories = categoriesList
+                        }
+                    } else {
+                        selectedCategories.remove(item)
+                    }
+                }
+            }
+        }
+
+        /**
+         * Get Selected Categories
+         * @return
+         */
+        fun getSelectedCategories(): List = selectedCategories
+
+        /**
+         * Cleanup the existing in memory cache's
+         */
+        fun cleanUp() {
+            selectedCategories.clear()
+            selectedExistingCategories.clear()
+        }
+
+        companion object {
+            const val SEARCH_CATS_LIMIT = 25
+        }
+
+        /**
+         * Provides selected existing categories
+         *
+         * @return selected existing categories
+         */
+        fun getSelectedExistingCategories(): List = selectedExistingCategories
+
+        /**
+         * Initialize existing categories
+         *
+         * @param selectedExistingCategories existing categories
+         */
+        fun setSelectedExistingCategories(selectedExistingCategories: MutableList) {
+            this.selectedExistingCategories = selectedExistingCategories
+        }
+    }
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java
deleted file mode 100644
index 7fc8f9d68..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.CheckedTextView;
-
-import com.pedrogomez.renderers.Renderer;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
-import timber.log.Timber;
-
-/**
- * Renders the Categories view
- */
-public class CategoriesRenderer extends Renderer {
-    @BindView(R.id.tvName) CheckedTextView checkedView;
-    private final CategoryClickedListener listener;
-
-    CategoriesRenderer(CategoryClickedListener listener) {
-        this.listener = listener;
-    }
-
-    @Override
-    protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) {
-        return layoutInflater.inflate(R.layout.layout_categories_item, viewGroup, false);
-    }
-
-    @Override
-    protected void setUpView(View view) {
-        ButterKnife.bind(this, view);
-    }
-
-    @Override
-    protected void hookListeners(View view) {
-        view.setOnClickListener(v -> {
-            CategoryItem item = getContent();
-            item.setSelected(!item.isSelected());
-            checkedView.setChecked(item.isSelected());
-            if (listener != null) {
-                listener.categoryClicked(item);
-            }
-        });
-    }
-
-    @Override
-    public void render() {
-        CategoryItem item = getContent();
-        Timber.e("Rendering: %s", item);
-        checkedView.setChecked(item.isSelected());
-        checkedView.setText(item.getName());
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.java b/app/src/main/java/fr/free/nrw/commons/category/Category.java
deleted file mode 100644
index 8ea3c442c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/Category.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.net.Uri;
-
-import java.util.Date;
-
-/**
- * Represents a category
- */
-public class Category {
-    private Uri contentUri;
-    private String name;
-    private Date lastUsed;
-    private int timesUsed;
-
-    public Category() {
-    }
-
-    public Category(Uri contentUri, String name, Date lastUsed, int timesUsed) {
-        this.contentUri = contentUri;
-        this.name = name;
-        this.lastUsed = lastUsed;
-        this.timesUsed = timesUsed;
-    }
-
-    /**
-     * Gets name
-     *
-     * @return name
-     */
-    public String getName() {
-        return name;
-    }
-
-    /**
-     * Modifies name
-     *
-     * @param name Category name
-     */
-    public void setName(String name) {
-        this.name = name;
-    }
-
-    /**
-     * Gets last used date
-     *
-     * @return Last used date
-     */
-    public Date getLastUsed() {
-        // warning: Date objects are mutable.
-        return (Date)lastUsed.clone();
-    }
-
-    /**
-     * Generates new last used date
-     */
-    private void touch() {
-        lastUsed = new Date();
-    }
-
-    /**
-     * Gets no. of times the category is used
-     *
-     * @return no. of times used
-     */
-    public int getTimesUsed() {
-        return timesUsed;
-    }
-
-    /**
-     * Increments timesUsed by 1 and sets last used date as now.
-     */
-    public void incTimesUsed() {
-        timesUsed++;
-        touch();
-    }
-
-    /**
-     * Gets the content URI for this category
-     *
-     * @return content URI
-     */
-    public Uri getContentUri() {
-        return contentUri;
-    }
-
-    /**
-     * Modifies the content URI - marking this category as already saved in the database
-     *
-     * @param contentUri the content URI
-     */
-    public void setContentUri(Uri contentUri) {
-        this.contentUri = contentUri;
-    }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt
new file mode 100644
index 000000000..e4bfb957a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt
@@ -0,0 +1,17 @@
+package fr.free.nrw.commons.category
+
+import android.net.Uri
+import java.util.Date
+
+data class Category(
+    var contentUri: Uri? = null,
+    val name: String? = null,
+    val description: String? = null,
+    val thumbnail: String? = null,
+    val lastUsed: Date? = null,
+    var timesUsed: Int = 0
+) {
+    fun incTimesUsed() {
+        timesUsed++
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java
deleted file mode 100644
index df99b4060..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package fr.free.nrw.commons.category;
-
-public interface CategoryClickedListener {
-    void categoryClicked(CategoryItem item);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt
new file mode 100644
index 000000000..ef4ec3d39
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.category
+
+interface CategoryClickedListener {
+    fun categoryClicked(item: CategoryItem)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java
deleted file mode 100644
index 329c3635a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package fr.free.nrw.commons.category;
-
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.dataclient.mwapi.MwQueryPage;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-import org.wikipedia.dataclient.mwapi.MwQueryResult;
-
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import io.reactivex.Observable;
-import timber.log.Timber;
-
-/**
- * Category Client to handle custom calls to Commons MediaWiki APIs
- */
-@Singleton
-public class CategoryClient {
-
-    private final CategoryInterface CategoryInterface;
-
-    @Inject
-    public CategoryClient(CategoryInterface CategoryInterface) {
-        this.CategoryInterface = CategoryInterface;
-    }
-
-    /**
-     * Searches for categories containing the specified string.
-     *
-     * @param filter    The string to be searched
-     * @param itemLimit How many results are returned
-     * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
-     * @return
-     */
-    public Observable searchCategories(String filter, int itemLimit, int offset) {
-        return responseToCategoryName(CategoryInterface.searchCategories(filter, itemLimit, offset));
-
-    }
-
-    /**
-     * Searches for categories containing the specified string.
-     *
-     * @param filter    The string to be searched
-     * @param itemLimit How many results are returned
-     * @return
-     */
-    public Observable searchCategories(String filter, int itemLimit) {
-        return searchCategories(filter, itemLimit, 0);
-
-    }
-
-    /**
-     * Searches for categories starting with the specified string.
-     *
-     * @param prefix    The prefix to be searched
-     * @param itemLimit How many results are returned
-     * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
-     * @return
-     */
-    public Observable searchCategoriesForPrefix(String prefix, int itemLimit, int offset) {
-        return responseToCategoryName(CategoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset));
-    }
-
-    /**
-     * Searches for categories starting with the specified string.
-     *
-     * @param prefix    The prefix to be searched
-     * @param itemLimit How many results are returned
-     * @return
-     */
-    public Observable searchCategoriesForPrefix(String prefix, int itemLimit) {
-        return searchCategoriesForPrefix(prefix, itemLimit, 0);
-    }
-
-
-    /**
-     * The method takes categoryName as input and returns a List of Subcategories
-     * It uses the generator query API to get the subcategories in a category, 500 at a time.
-     *
-     * @param categoryName Category name as defined on commons
-     * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
-     */
-    public Observable getSubCategoryList(String categoryName) {
-        return responseToCategoryName(CategoryInterface.getSubCategoryList(categoryName));
-    }
-
-    /**
-     * The method takes categoryName as input and returns a List of parent categories
-     * It uses the generator query API to get the parent categories of a category, 500 at a time.
-     *
-     * @param categoryName Category name as defined on commons
-     * @return
-     */
-    @NonNull
-    public Observable getParentCategoryList(String categoryName) {
-        return responseToCategoryName(CategoryInterface.getParentCategoryList(categoryName));
-    }
-
-    /**
-     * Internal function to reduce code reuse. Extracts the categories returned from MwQueryResponse.
-     *
-     * @param responseObservable The query response observable
-     * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
-     */
-    private Observable responseToCategoryName(Observable responseObservable) {
-        return responseObservable
-                .flatMap(mwQueryResponse -> {
-                    MwQueryResult query;
-                    List pages;
-                    if ((query = mwQueryResponse.query()) == null ||
-                            (pages = query.pages()) == null) {
-                        Timber.d("No categories returned.");
-                        return Observable.empty();
-                    } else
-                        return Observable.fromIterable(pages);
-                })
-                .map(MwQueryPage::title)
-                .doOnEach(s -> Timber.d("Category returned: %s", s))
-                .map(cat -> cat.replace("Category:", ""));
-    }
-}
\ No newline at end of file
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
new file mode 100644
index 000000000..b031f12f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
@@ -0,0 +1,157 @@
+package fr.free.nrw.commons.category
+
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Single
+import javax.inject.Inject
+import javax.inject.Singleton
+
+const val CATEGORY_PREFIX = "Category:"
+const val SUB_CATEGORY_CONTINUATION_PREFIX = "sub_category_"
+const val PARENT_CATEGORY_CONTINUATION_PREFIX = "parent_category_"
+const val CATEGORY_UNCATEGORISED = "uncategorised"
+const val CATEGORY_NEEDING_CATEGORIES = "needing categories"
+
+/**
+ * Category Client to handle custom calls to Commons MediaWiki APIs
+ */
+@Singleton
+class CategoryClient
+    @Inject
+    constructor(
+        private val categoryInterface: CategoryInterface,
+    ) : ContinuationClient() {
+        /**
+         * Searches for categories containing the specified string.
+         *
+         * @param filter    The string to be searched
+         * @param itemLimit How many results are returned
+         * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
+         * @return
+         */
+        @JvmOverloads
+        fun searchCategories(
+            filter: String?,
+            itemLimit: Int,
+            offset: Int = 0,
+        ): Single> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset))
+
+        /**
+         * Searches for categories starting with the specified string.
+         *
+         * @param prefix    The prefix to be searched
+         * @param itemLimit How many results are returned
+         * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
+         * @return
+         */
+        @JvmOverloads
+        fun searchCategoriesForPrefix(
+            prefix: String?,
+            itemLimit: Int,
+            offset: Int = 0,
+        ): Single> =
+            responseMapper(
+                categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset),
+            )
+
+        /**
+         * Fetches categories starting and ending with a specified name.
+         *
+         * @param startingCategoryName Name of the category to start
+         * @param endingCategoryName Name of the category to end
+         * @param itemLimit How many categories to return
+         * @param offset offset
+         * @return MwQueryResponse
+         */
+        @JvmOverloads
+        fun getCategoriesByName(
+            startingCategoryName: String?,
+            endingCategoryName: String?,
+            itemLimit: Int,
+            offset: Int = 0,
+        ): Single> =
+            responseMapper(
+                categoryInterface.getCategoriesByName(
+                    startingCategoryName,
+                    endingCategoryName,
+                    itemLimit,
+                    offset,
+                ),
+            )
+
+        /**
+         * Fetches categories belonging to an image (P18 of some wikidata entity).
+         *
+         * @param image P18 of some wikidata entity
+         * @param itemLimit How many categories to return
+         * @return Single Observable emitting the list of categories
+         */
+        fun getCategoriesOfImage(
+            image: String,
+            itemLimit: Int,
+        ): Single> =
+            responseMapper(
+                categoryInterface.getCategoriesByTitles(
+                    "File:${image}",
+                    itemLimit,
+                ),
+            )
+
+        /**
+         * The method takes categoryName as input and returns a List of Subcategories
+         * It uses the generator query API to get the subcategories in a category, 500 at a time.
+         *
+         * @param categoryName Category name as defined on commons
+         * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
+         */
+        fun getSubCategoryList(categoryName: String): Single> =
+            continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) {
+                categoryInterface.getSubCategoryList(
+                    categoryName,
+                    it,
+                )
+            }
+
+        /**
+         * The method takes categoryName as input and returns a List of parent categories
+         * It uses the generator query API to get the parent categories of a category, 500 at a time.
+         *
+         * @param categoryName Category name as defined on commons
+         * @return
+         */
+        fun getParentCategoryList(categoryName: String): Single> =
+            continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) {
+                categoryInterface.getParentCategoryList(categoryName, it)
+            }
+
+        fun resetSubCategoryContinuation(category: String) {
+            resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category)
+        }
+
+        fun resetParentCategoryContinuation(category: String) {
+            resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category)
+        }
+
+        override fun responseMapper(
+            networkResult: Single,
+            key: String?,
+        ): Single> =
+            networkResult
+                .map {
+                    handleContinuationResponse(it.continuation(), key)
+                    it.query()?.pages() ?: emptyList()
+                }.map {
+                    it
+                        .filter { page ->
+                            // 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, ""),
+                                it.description().toString(),
+                                it.thumbUrl().toString(),
+                                false,
+                            )
+                        }
+                }
+    }
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java
deleted file mode 100644
index 01793ca95..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java
+++ /dev/null
@@ -1,169 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.ContentValues;
-import android.content.UriMatcher;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteQueryBuilder;
-import android.net.Uri;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.data.DBOpenHelper;
-import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
-import timber.log.Timber;
-
-import static android.content.UriMatcher.NO_MATCH;
-import static fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS;
-import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID;
-import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME;
-
-public class CategoryContentProvider extends CommonsDaggerContentProvider {
-
-    // For URI matcher
-    private static final int CATEGORIES = 1;
-    private static final int CATEGORIES_ID = 2;
-    private static final String BASE_PATH = "categories";
-
-    public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH);
-
-    private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
-
-    static {
-        uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES);
-        uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID);
-    }
-
-    public static Uri uriForId(int id) {
-        return Uri.parse(BASE_URI.toString() + "/" + id);
-    }
-
-    @Inject DBOpenHelper dbOpenHelper;
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Cursor query(@NonNull Uri uri, String[] projection, String selection,
-                        String[] selectionArgs, String sortOrder) {
-        SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
-        queryBuilder.setTables(TABLE_NAME);
-
-        int uriType = uriMatcher.match(uri);
-
-        SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
-        Cursor cursor;
-
-        switch (uriType) {
-            case CATEGORIES:
-                cursor = queryBuilder.query(db, projection, selection, selectionArgs,
-                        null, null, sortOrder);
-                break;
-            case CATEGORIES_ID:
-                cursor = queryBuilder.query(db,
-                        ALL_FIELDS,
-                        "_id = ?",
-                        new String[]{uri.getLastPathSegment()},
-                        null,
-                        null,
-                        sortOrder
-                );
-                break;
-            default:
-                throw new IllegalArgumentException("Unknown URI" + uri);
-        }
-
-        cursor.setNotificationUri(getContext().getContentResolver(), uri);
-
-        return cursor;
-    }
-
-    @Override
-    public String getType(@NonNull Uri uri) {
-        return null;
-    }
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
-        int uriType = uriMatcher.match(uri);
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        long id;
-        switch (uriType) {
-            case CATEGORIES:
-                id = sqlDB.insert(TABLE_NAME, null, contentValues);
-                break;
-            default:
-                throw new IllegalArgumentException("Unknown URI: " + uri);
-        }
-        getContext().getContentResolver().notifyChange(uri, null);
-        return Uri.parse(BASE_URI + "/" + id);
-    }
-
-    @Override
-    public int delete(@NonNull Uri uri, String s, String[] strings) {
-        return 0;
-    }
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
-        Timber.d("Hello, bulk insert! (CategoryContentProvider)");
-        int uriType = uriMatcher.match(uri);
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        sqlDB.beginTransaction();
-        switch (uriType) {
-            case CATEGORIES:
-                for (ContentValues value : values) {
-                    Timber.d("Inserting! %s", value);
-                    sqlDB.insert(TABLE_NAME, null, value);
-                }
-                break;
-            default:
-                throw new IllegalArgumentException("Unknown URI: " + uri);
-        }
-        sqlDB.setTransactionSuccessful();
-        sqlDB.endTransaction();
-        getContext().getContentResolver().notifyChange(uri, null);
-        return values.length;
-    }
-
-    @SuppressWarnings("ConstantConditions")
-    @Override
-    public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
-                      String[] selectionArgs) {
-        /*
-        SQL Injection warnings: First, note that we're not exposing this to the
-        outside world (exported="false"). Even then, we should make sure to sanitize
-        all user input appropriately. Input that passes through ContentValues
-        should be fine. So only issues are those that pass in via concating.
-
-        In here, the only concat created argument is for id. It is cast to an int,
-        and will error out otherwise.
-         */
-        int uriType = uriMatcher.match(uri);
-        SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
-        int rowsUpdated;
-        switch (uriType) {
-            case CATEGORIES_ID:
-                if (TextUtils.isEmpty(selection)) {
-                    int id = Integer.valueOf(uri.getLastPathSegment());
-                    rowsUpdated = sqlDB.update(TABLE_NAME,
-                            contentValues,
-                            COLUMN_ID + " = ?",
-                            new String[]{String.valueOf(id)});
-                } else {
-                    throw new IllegalArgumentException(
-                            "Parameter `selection` should be empty when updating an ID");
-                }
-                break;
-            default:
-                throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
-        }
-        getContext().getContentResolver().notifyChange(uri, null);
-        return rowsUpdated;
-    }
-}
-
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt
new file mode 100644
index 000000000..f5cec0fce
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt
@@ -0,0 +1,188 @@
+package fr.free.nrw.commons.category
+
+
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.content.UriMatcher.NO_MATCH
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteQueryBuilder
+import android.net.Uri
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.di.CommonsDaggerContentProvider
+import androidx.core.net.toUri
+
+class CategoryContentProvider : CommonsDaggerContentProvider() {
+
+    private val uriMatcher = UriMatcher(NO_MATCH).apply {
+        addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES)
+        addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID)
+    }
+
+    @SuppressWarnings("ConstantConditions")
+    override fun query(uri: Uri, projection: Array?, selection: String?,
+                       selectionArgs: Array?, sortOrder: String?): Cursor? {
+        val queryBuilder = SQLiteQueryBuilder().apply {
+            tables = TABLE_NAME
+        }
+
+        val uriType = uriMatcher.match(uri)
+        val db = requireDb()
+
+        val cursor: Cursor? = when (uriType) {
+            CATEGORIES -> queryBuilder.query(
+                db,
+                projection,
+                selection,
+                selectionArgs,
+                null,
+                null,
+                sortOrder
+            )
+            CATEGORIES_ID -> queryBuilder.query(
+                db,
+                ALL_FIELDS,
+                "_id = ?",
+                arrayOf(uri.lastPathSegment),
+                null,
+                null,
+                sortOrder
+            )
+            else -> throw IllegalArgumentException("Unknown URI $uri")
+        }
+
+        cursor?.setNotificationUri(requireContext().contentResolver, uri)
+        return cursor
+    }
+
+    override fun getType(uri: Uri): String? = null
+
+    @SuppressWarnings("ConstantConditions")
+    override fun insert(uri: Uri, contentValues: ContentValues?): Uri {
+        val uriType = uriMatcher.match(uri)
+        val id: Long
+        when (uriType) {
+            CATEGORIES -> {
+                id = requireDb().insert(TABLE_NAME, null, contentValues)
+            }
+            else -> throw IllegalArgumentException("Unknown URI: $uri")
+        }
+        requireContext().contentResolver?.notifyChange(uri, null)
+        return "${BASE_URI}/$id".toUri()
+    }
+
+    @SuppressWarnings("ConstantConditions")
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+    @SuppressWarnings("ConstantConditions")
+    override fun bulkInsert(uri: Uri, values: Array): Int {
+        val uriType = uriMatcher.match(uri)
+        val sqlDB = requireDb()
+        sqlDB.beginTransaction()
+        when (uriType) {
+            CATEGORIES -> {
+                for (value in values) {
+                    sqlDB.insert(TABLE_NAME, null, value)
+                }
+                sqlDB.setTransactionSuccessful()
+            }
+            else -> throw IllegalArgumentException("Unknown URI: $uri")
+        }
+        sqlDB.endTransaction()
+        requireContext().contentResolver?.notifyChange(uri, null)
+        return values.size
+    }
+
+    @SuppressWarnings("ConstantConditions")
+    override fun update(uri: Uri, contentValues: ContentValues?, selection: String?,
+                        selectionArgs: Array?): Int {
+        val uriType = uriMatcher.match(uri)
+        val rowsUpdated: Int
+        when (uriType) {
+            CATEGORIES_ID -> {
+                if (TextUtils.isEmpty(selection)) {
+                    val id = uri.lastPathSegment?.toInt()
+                        ?: throw IllegalArgumentException("Invalid ID")
+                    rowsUpdated = requireDb().update(
+                        TABLE_NAME,
+                        contentValues,
+                        "$COLUMN_ID = ?",
+                        arrayOf(id.toString())
+                    )
+                } else {
+                    throw IllegalArgumentException(
+                        "Parameter `selection` should be empty when updating an ID")
+                }
+            }
+            else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType")
+        }
+        requireContext().contentResolver?.notifyChange(uri, null)
+        return rowsUpdated
+    }
+
+    companion object {
+        const val TABLE_NAME = "categories"
+
+        const val COLUMN_ID = "_id"
+        const val COLUMN_NAME = "name"
+        const val COLUMN_DESCRIPTION = "description"
+        const val COLUMN_THUMBNAIL = "thumbnail"
+        const val COLUMN_LAST_USED = "last_used"
+        const val COLUMN_TIMES_USED = "times_used"
+
+        // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+        val ALL_FIELDS = arrayOf(
+            COLUMN_ID,
+            COLUMN_NAME,
+            COLUMN_DESCRIPTION,
+            COLUMN_THUMBNAIL,
+            COLUMN_LAST_USED,
+            COLUMN_TIMES_USED
+        )
+
+        const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+        const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
+                "$COLUMN_ID INTEGER PRIMARY KEY," +
+                "$COLUMN_NAME TEXT," +
+                "$COLUMN_DESCRIPTION TEXT," +
+                "$COLUMN_THUMBNAIL TEXT," +
+                "$COLUMN_LAST_USED INTEGER," +
+                "$COLUMN_TIMES_USED INTEGER" +
+                ");"
+
+        fun uriForId(id: Int): Uri = Uri.parse("${BASE_URI}/$id")
+
+        fun onCreate(db: SQLiteDatabase) = db.execSQL(CREATE_TABLE_STATEMENT)
+
+        fun onDelete(db: SQLiteDatabase) {
+            db.execSQL(DROP_TABLE_STATEMENT)
+            onCreate(db)
+        }
+
+        fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+            if (from == to) return
+            if (from < 4) {
+                // doesn't exist yet
+                onUpdate(db, from + 1, to)
+            } else if (from == 4) {
+                // table added in version 5
+                onCreate(db)
+                onUpdate(db, from + 1, to)
+            } else if (from == 5) {
+                onUpdate(db, from + 1, to)
+            } else if (from == 17) {
+                db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;")
+                db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;")
+                onUpdate(db, from + 1, to)
+            }
+        }
+
+        // For URI matcher
+        private const val CATEGORIES = 1
+        private const val CATEGORIES_ID = 2
+        private const val BASE_PATH = "categories"
+        val  BASE_URI: Uri = "content://${BuildConfig.CATEGORY_AUTHORITY}/${BASE_PATH}".toUri()
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java
deleted file mode 100644
index f51700313..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java
+++ /dev/null
@@ -1,186 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.ContentProviderClient;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.RemoteException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Provider;
-
-public class CategoryDao {
-
-    private final Provider clientProvider;
-
-    @Inject
-    public CategoryDao(@Named("category") Provider clientProvider) {
-        this.clientProvider = clientProvider;
-    }
-
-    public void save(Category category) {
-        ContentProviderClient db = clientProvider.get();
-        try {
-            if (category.getContentUri() == null) {
-                category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
-            } else {
-                db.update(category.getContentUri(), toContentValues(category), null, null);
-            }
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            db.release();
-        }
-    }
-
-    /**
-     * Find persisted category in database, based on its name.
-     *
-     * @param name Category's name
-     * @return category from database, or null if not found
-     */
-    @Nullable
-    Category find(String name) {
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    CategoryContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    Table.COLUMN_NAME + "=?",
-                    new String[]{name},
-                    null);
-            if (cursor != null && cursor.moveToFirst()) {
-                return fromCursor(cursor);
-            }
-        } catch (RemoteException e) {
-            // This feels lazy, but to hell with checked exceptions. :)
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return null;
-    }
-
-    /**
-     * Retrieve recently-used categories, ordered by descending date.
-     *
-     * @return a list containing recent categories
-     */
-    @NonNull
-    List recentCategories(int limit) {
-        List items = new ArrayList<>();
-        Cursor cursor = null;
-        ContentProviderClient db = clientProvider.get();
-        try {
-            cursor = db.query(
-                    CategoryContentProvider.BASE_URI,
-                    Table.ALL_FIELDS,
-                    null,
-                    new String[]{},
-                    Table.COLUMN_LAST_USED + " DESC");
-            // fixme add a limit on the original query instead of falling out of the loop?
-            while (cursor != null && cursor.moveToNext()
-                    && cursor.getPosition() < limit) {
-                items.add(fromCursor(cursor).getName());
-            }
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-            db.release();
-        }
-        return items;
-    }
-
-    @NonNull
-    Category fromCursor(Cursor cursor) {
-        // Hardcoding column positions!
-        return new Category(
-                CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))),
-                cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
-                new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))),
-                cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED))
-        );
-    }
-
-    private ContentValues toContentValues(Category category) {
-        ContentValues cv = new ContentValues();
-        cv.put(CategoryDao.Table.COLUMN_NAME, category.getName());
-        cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime());
-        cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed());
-        return cv;
-    }
-
-    public static class Table {
-        public static final String TABLE_NAME = "categories";
-
-        public static final String COLUMN_ID = "_id";
-        static final String COLUMN_NAME = "name";
-        static final String COLUMN_LAST_USED = "last_used";
-        static final String COLUMN_TIMES_USED = "times_used";
-
-        // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
-        public static final String[] ALL_FIELDS = {
-                COLUMN_ID,
-                COLUMN_NAME,
-                COLUMN_LAST_USED,
-                COLUMN_TIMES_USED
-        };
-
-        static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
-
-        static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
-                + COLUMN_ID + " INTEGER PRIMARY KEY,"
-                + COLUMN_NAME + " STRING,"
-                + COLUMN_LAST_USED + " INTEGER,"
-                + COLUMN_TIMES_USED + " INTEGER"
-                + ");";
-
-        public static void onCreate(SQLiteDatabase db) {
-            db.execSQL(CREATE_TABLE_STATEMENT);
-        }
-
-        public static void onDelete(SQLiteDatabase db) {
-            db.execSQL(DROP_TABLE_STATEMENT);
-            onCreate(db);
-        }
-
-        public static void onUpdate(SQLiteDatabase db, int from, int to) {
-            if (from == to) {
-                return;
-            }
-            if (from < 4) {
-                // doesn't exist yet
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-            if (from == 4) {
-                // table added in version 5
-                onCreate(db);
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-            if (from == 5) {
-                from++;
-                onUpdate(db, from, to);
-                return;
-            }
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
new file mode 100644
index 000000000..3371da184
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
@@ -0,0 +1,194 @@
+package fr.free.nrw.commons.category
+
+import android.annotation.SuppressLint
+import android.content.ContentProviderClient
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.os.RemoteException
+
+import java.util.ArrayList
+import java.util.Date
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Provider
+
+class CategoryDao @Inject constructor(
+    @Named("category") private val clientProvider: Provider
+) {
+
+    fun save(category: Category) {
+        val db = clientProvider.get()
+        try {
+            if (category.contentUri == null) {
+                category.contentUri = db.insert(
+                    CategoryContentProvider.BASE_URI,
+                    toContentValues(category)
+                )
+            } else {
+                db.update(
+                    category.contentUri!!,
+                    toContentValues(category),
+                    null,
+                    null
+                )
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            db.release()
+        }
+    }
+
+    /**
+     * Find persisted category in database, based on its name.
+     *
+     * @param name Category's name
+     * @return category from database, or null if not found
+     */
+    fun find(name: String): Category? {
+        var cursor: Cursor? = null
+        val db = clientProvider.get()
+        try {
+            cursor = db.query(
+                CategoryContentProvider.BASE_URI,
+                ALL_FIELDS,
+                "${COLUMN_NAME}=?",
+                arrayOf(name),
+                null
+            )
+            if (cursor != null && cursor.moveToFirst()) {
+                return fromCursor(cursor)
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            cursor?.close()
+            db.release()
+        }
+        return null
+    }
+
+    /**
+     * Retrieve recently-used categories, ordered by descending date.
+     *
+     * @return a list containing recent categories
+     */
+    fun recentCategories(limit: Int): List {
+        val items = ArrayList()
+        var cursor: Cursor? = null
+        val db = clientProvider.get()
+        try {
+            cursor = db.query(
+                CategoryContentProvider.BASE_URI,
+                ALL_FIELDS,
+                null,
+                emptyArray(),
+                "$COLUMN_LAST_USED DESC"
+            )
+            while (cursor != null && cursor.moveToNext() && cursor.position < limit) {
+                val category = fromCursor(cursor)
+                if (category.name != null) {
+                    items.add(
+                        CategoryItem(
+                            category.name,
+                            category.description,
+                            category.thumbnail,
+                            false
+                        )
+                    )
+                }
+            }
+        } catch (e: RemoteException) {
+            throw RuntimeException(e)
+        } finally {
+            cursor?.close()
+            db.release()
+        }
+        return items
+    }
+
+    @SuppressLint("Range")
+    fun fromCursor(cursor: Cursor): Category {
+        // Hardcoding column positions!
+        return Category(
+            CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))),
+            cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
+            cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)),
+            cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)),
+            Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))),
+            cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED))
+        )
+    }
+
+    private fun toContentValues(category: Category): ContentValues {
+        return ContentValues().apply {
+            put(COLUMN_NAME, category.name)
+            put(COLUMN_DESCRIPTION, category.description)
+            put(COLUMN_THUMBNAIL, category.thumbnail)
+            put(COLUMN_LAST_USED, category.lastUsed?.time)
+            put(COLUMN_TIMES_USED, category.timesUsed)
+        }
+    }
+
+    companion object Table {
+        const val TABLE_NAME = "categories"
+
+        const val COLUMN_ID = "_id"
+        const val COLUMN_NAME = "name"
+        const val COLUMN_DESCRIPTION = "description"
+        const val COLUMN_THUMBNAIL = "thumbnail"
+        const val COLUMN_LAST_USED = "last_used"
+        const val COLUMN_TIMES_USED = "times_used"
+
+        // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+        val ALL_FIELDS = arrayOf(
+            COLUMN_ID,
+            COLUMN_NAME,
+            COLUMN_DESCRIPTION,
+            COLUMN_THUMBNAIL,
+            COLUMN_LAST_USED,
+            COLUMN_TIMES_USED
+        )
+
+        const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+        const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
+                "$COLUMN_ID INTEGER PRIMARY KEY," +
+                "$COLUMN_NAME STRING," +
+                "$COLUMN_DESCRIPTION STRING," +
+                "$COLUMN_THUMBNAIL STRING," +
+                "$COLUMN_LAST_USED INTEGER," +
+                "$COLUMN_TIMES_USED INTEGER" +
+                ");"
+
+        @SuppressLint("SQLiteString")
+        fun onCreate(db: SQLiteDatabase) {
+            db.execSQL(CREATE_TABLE_STATEMENT)
+        }
+
+        fun onDelete(db: SQLiteDatabase) {
+            db.execSQL(DROP_TABLE_STATEMENT)
+            onCreate(db)
+        }
+
+        @SuppressLint("SQLiteString")
+        fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+            if (from == to) return
+            if (from < 4) {
+                // doesn't exist yet
+                onUpdate(db, from + 1, to)
+            } else if (from == 4) {
+                // table added in version 5
+                onCreate(db)
+                onUpdate(db, from + 1, to)
+            } else if (from == 5) {
+                onUpdate(db, from + 1, to)
+            } else if (from == 17) {
+                db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;")
+                db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;")
+                onUpdate(db, from + 1, to)
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java
deleted file mode 100644
index 597acc18a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java
+++ /dev/null
@@ -1,239 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.AdapterView;
-import android.widget.FrameLayout;
-
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.viewpager.widget.ViewPager;
-
-import com.google.android.material.tabs.TabLayout;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.explore.ViewPagerAdapter;
-import fr.free.nrw.commons.media.MediaDetailPagerFragment;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-
-/**
- * This activity displays details of a particular category
- * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
- * a particular category on wikimedia commons.
- */
-
-public class CategoryDetailsActivity extends NavigationBaseActivity
-        implements MediaDetailPagerFragment.MediaDetailProvider,
-                    AdapterView.OnItemClickListener, CategoryImagesCallback {
-
-
-    private FragmentManager supportFragmentManager;
-    private CategoryImagesListFragment categoryImagesListFragment;
-    private MediaDetailPagerFragment mediaDetails;
-    private String categoryName;
-    @BindView(R.id.mediaContainer) FrameLayout mediaContainer;
-    @BindView(R.id.tab_layout) TabLayout tabLayout;
-    @BindView(R.id.viewPager) ViewPager viewPager;
-
-    ViewPagerAdapter viewPagerAdapter;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_category_details);
-        ButterKnife.bind(this);
-        supportFragmentManager = getSupportFragmentManager();
-        viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
-        viewPager.setAdapter(viewPagerAdapter);
-        viewPager.setOffscreenPageLimit(2);
-        tabLayout.setupWithViewPager(viewPager);
-        setTabs();
-        setPageTitle();
-        initDrawer();
-        forceInitBackButton();
-    }
-
-    /**
-     * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
-     * Set the fragments according to the tab selected in the viewPager.
-     */
-    private void setTabs() {
-        List fragmentList = new ArrayList<>();
-        List titleList = new ArrayList<>();
-        categoryImagesListFragment = new CategoryImagesListFragment();
-        SubCategoryListFragment subCategoryListFragment = new SubCategoryListFragment();
-        SubCategoryListFragment parentCategoryListFragment = new SubCategoryListFragment();
-        categoryName = getIntent().getStringExtra("categoryName");
-        if (getIntent() != null && categoryName != null) {
-            Bundle arguments = new Bundle();
-            arguments.putString("categoryName", categoryName);
-            arguments.putBoolean("isParentCategory", false);
-            categoryImagesListFragment.setArguments(arguments);
-            subCategoryListFragment.setArguments(arguments);
-            Bundle parentCategoryArguments = new Bundle();
-            parentCategoryArguments.putString("categoryName", categoryName);
-            parentCategoryArguments.putBoolean("isParentCategory", true);
-            parentCategoryListFragment.setArguments(parentCategoryArguments);
-        }
-        fragmentList.add(categoryImagesListFragment);
-        titleList.add("MEDIA");
-        fragmentList.add(subCategoryListFragment);
-        titleList.add("SUBCATEGORIES");
-        fragmentList.add(parentCategoryListFragment);
-        titleList.add("PARENT CATEGORIES");
-        viewPagerAdapter.setTabData(fragmentList, titleList);
-        viewPagerAdapter.notifyDataSetChanged();
-
-    }
-
-    /**
-     * Gets the passed categoryName from the intents and displays it as the page title
-     */
-    private void setPageTitle() {
-        if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) {
-            setTitle(getIntent().getStringExtra("categoryName"));
-        }
-    }
-
-    /**
-     * This method is called onClick of media inside category details (CategoryImageListFragment).
-     */
-    @Override
-    public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
-        tabLayout.setVisibility(View.GONE);
-        viewPager.setVisibility(View.GONE);
-        mediaContainer.setVisibility(View.VISIBLE);
-        if (mediaDetails == null || !mediaDetails.isVisible()) {
-            // set isFeaturedImage true for featured images, to include author field on media detail
-            mediaDetails = new MediaDetailPagerFragment(false, true);
-            FragmentManager supportFragmentManager = getSupportFragmentManager();
-            supportFragmentManager
-                    .beginTransaction()
-                    .replace(R.id.mediaContainer, mediaDetails)
-                    .addToBackStack(null)
-                    .commit();
-            supportFragmentManager.executePendingTransactions();
-        }
-        mediaDetails.showImage(i);
-        forceInitBackButton();
-    }
-
-
-    /**
-     * Consumers should be simply using this method to use this activity.
-     * @param context  A Context of the application package implementing this class.
-     * @param categoryName Name of the category for displaying its details
-     */
-    public static void startYourself(Context context, String categoryName) {
-        Intent intent = new Intent(context, CategoryDetailsActivity.class);
-        intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        intent.putExtra("categoryName", categoryName);
-        context.startActivity(intent);
-    }
-
-    /**
-     * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
-     * @param i It is the index of which media object is to be returned which is same as
-     *          current index of viewPager.
-     * @return Media Object
-     */
-    @Override
-    public Media getMediaAtPosition(int i) {
-        if (categoryImagesListFragment.getAdapter() == null) {
-            // not yet ready to return data
-            return null;
-        } else {
-            return (Media) categoryImagesListFragment.getAdapter().getItem(i);
-        }
-    }
-
-    /**
-     * This method is called on from getCount of MediaDetailPagerFragment
-     * The viewpager will contain same number of media items as that of media elements in adapter.
-     * @return Total Media count in the adapter
-     */
-    @Override
-    public int getTotalMediaCount() {
-        if (categoryImagesListFragment.getAdapter() == null) {
-            return 0;
-        }
-        return categoryImagesListFragment.getAdapter().getCount();
-    }
-
-    /**
-     * This method inflates the menu in the toolbar
-     */
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        MenuInflater inflater = getMenuInflater();
-        inflater.inflate(R.menu.fragment_category_detail, menu);
-        return super.onCreateOptionsMenu(menu);
-    }
-
-    /**
-     * This method handles the logic on ItemSelect in toolbar menu
-     * Currently only 1 choice is available to open category details page in browser
-     */
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-
-        // Handle item selection
-        switch (item.getItemId()) {
-            case R.id.menu_browser_current_category:
-                Utils.handleWebUrl(this, Uri.parse(Utils.getPageTitle(categoryName).getCanonicalUri()));
-                return true;
-            default:
-                return super.onOptionsItemSelected(item);
-        }
-    }
-
-    /**
-     * This method is called on backPressed of anyFragment in the activity.
-     * If condition is called when mediaDetailFragment is opened.
-     */
-    @Override
-    public void onBackPressed() {
-        if (supportFragmentManager.getBackStackEntryCount() == 1){
-            // back to search so show search toolbar and hide navigation toolbar
-            tabLayout.setVisibility(View.VISIBLE);
-            viewPager.setVisibility(View.VISIBLE);
-            mediaContainer.setVisibility(View.GONE);
-        }
-        super.onBackPressed();
-    }
-
-    /**
-     * This method is called on success of API call for Images inside a category.
-     * The viewpager will notified that number of items have changed.
-     */
-    @Override
-    public void viewPagerNotifyDataSetChanged() {
-        if (mediaDetails!=null){
-            mediaDetails.notifyDataSetChanged();
-        }
-    }
-
-    /**
-     * This method is called when viewPager has reached its end.
-     * Fetches more images using search query and adds it to the grid view and viewpager adapter
-     */
-    @Override
-    public void requestMoreImages() {
-        if (categoryImagesListFragment!=null){
-            categoryImagesListFragment.fetchMoreImagesViewPager();
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
new file mode 100644
index 000000000..fefe462a9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
@@ -0,0 +1,265 @@
+package fr.free.nrw.commons.category
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import fr.free.nrw.commons.BuildConfig.COMMONS_URL
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.ViewPagerAdapter
+import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding
+import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment
+import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment
+import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment
+import fr.free.nrw.commons.media.MediaDetailPagerFragment
+import fr.free.nrw.commons.media.MediaDetailProvider
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import fr.free.nrw.commons.wikidata.model.WikiSite
+import fr.free.nrw.commons.wikidata.model.page.PageTitle
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+/**
+ * This activity displays details of a particular category
+ * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
+ * a particular category on wikimedia commons.
+ */
+class CategoryDetailsActivity : BaseActivity(),
+    MediaDetailProvider,
+    CategoryImagesCallback {
+
+    private lateinit var supportFragmentManager: FragmentManager
+    private lateinit var categoriesMediaFragment: CategoriesMediaFragment
+    private var mediaDetails: MediaDetailPagerFragment? = null
+    private var categoryName: String? = null
+    private lateinit var viewPagerAdapter: ViewPagerAdapter
+
+    private lateinit var binding: ActivityCategoryDetailsBinding
+
+    @Inject
+    lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory
+
+    private val viewModel: CategoryDetailsViewModel by viewModels { categoryViewModelFactory }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        binding = ActivityCategoryDetailsBinding.inflate(layoutInflater)
+        val view = binding.root
+        applyEdgeToEdgeAllInsets(view)
+        setContentView(view)
+        supportFragmentManager = getSupportFragmentManager()
+        viewPagerAdapter = ViewPagerAdapter(this, supportFragmentManager)
+        binding.viewPager.adapter = viewPagerAdapter
+        binding.viewPager.offscreenPageLimit = 2
+        binding.tabLayout.setupWithViewPager(binding.viewPager)
+        setSupportActionBar(binding.toolbarBinding.toolbar)
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+        setTabs()
+        setPageTitle()
+
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED){
+                viewModel.bookmarkState.collect {
+                    invalidateOptionsMenu()
+                }
+            }
+        }
+
+    }
+
+    /**
+     * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
+     * Set the fragments according to the tab selected in the viewPager.
+     */
+    private fun setTabs() {
+        categoriesMediaFragment = CategoriesMediaFragment()
+        val subCategoryListFragment = SubCategoriesFragment()
+        val parentCategoriesFragment = ParentCategoriesFragment()
+        categoryName = intent?.getStringExtra("categoryName")
+        if (intent != null && categoryName != null) {
+            val arguments = Bundle().apply {
+                putString("categoryName", categoryName)
+            }
+            categoriesMediaFragment.arguments = arguments
+            subCategoryListFragment.arguments = arguments
+            parentCategoriesFragment.arguments = arguments
+
+            viewModel.onCheckIfBookmarked(categoryName!!)
+        }
+
+        viewPagerAdapter.setTabs(
+            R.string.title_for_media to categoriesMediaFragment,
+            R.string.title_for_subcategories to subCategoryListFragment,
+            R.string.title_for_parent_categories to parentCategoriesFragment
+        )
+        viewPagerAdapter.notifyDataSetChanged()
+    }
+
+    /**
+     * Gets the passed categoryName from the intents and displays it as the page title
+     */
+    private fun setPageTitle() {
+        intent?.getStringExtra("categoryName")?.let {
+            title = it
+        }
+    }
+
+    /**
+     * This method is called onClick of media inside category details (CategoryImageListFragment).
+     */
+    override fun onMediaClicked(position: Int) {
+        binding.tabLayout.visibility = View.GONE
+        binding.viewPager.visibility = View.GONE
+        binding.mediaContainer.visibility = View.VISIBLE
+        if (mediaDetails == null || mediaDetails?.isVisible == false) {
+            // set isFeaturedImage true for featured images, to include author field on media detail
+            mediaDetails = MediaDetailPagerFragment.newInstance(false, true)
+            supportFragmentManager.beginTransaction()
+                .replace(R.id.mediaContainer, mediaDetails!!)
+                .addToBackStack(null)
+                .commit()
+            supportFragmentManager.executePendingTransactions()
+        }
+        mediaDetails?.showImage(position)
+    }
+
+
+    companion object {
+        /**
+         * Consumers should be simply using this method to use this activity.
+         * @param context  A Context of the application package implementing this class.
+         * @param categoryName Name of the category for displaying its details
+         */
+        fun startYourself(context: Context?, categoryName: String) {
+            val intent = Intent(context, CategoryDetailsActivity::class.java).apply {
+                putExtra("categoryName", categoryName)
+            }
+            context?.startActivity(intent)
+        }
+    }
+
+    /**
+     * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
+     * @param i It is the index of which media object is to be returned which is same as
+     *          current index of viewPager.
+     * @return Media Object
+     */
+    override fun getMediaAtPosition(i: Int): Media? {
+        return categoriesMediaFragment.getMediaAtPosition(i)
+    }
+
+    /**
+     * This method is called on from getCount of MediaDetailPagerFragment
+     * The viewpager will contain same number of media items as that of media elements in adapter.
+     * @return Total Media count in the adapter
+     */
+    override fun getTotalMediaCount(): Int {
+        return categoriesMediaFragment.getTotalMediaCount()
+    }
+
+    override fun getContributionStateAt(position: Int): Int? {
+        return null
+    }
+
+    /**
+     * Reload media detail fragment once media is nominated
+     *
+     * @param index item position that has been nominated
+     */
+    override fun refreshNominatedMedia(index: Int) {
+        if (supportFragmentManager.backStackEntryCount == 1) {
+            onBackPressed()
+            onMediaClicked(index)
+        }
+    }
+
+    /**
+     * This method inflates the menu in the toolbar
+     */
+    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+        menuInflater.inflate(R.menu.fragment_category_detail, menu)
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    /**
+     * This method handles the logic on ItemSelect in toolbar menu
+     * Currently only 1 choice is available to open category details page in browser
+     */
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return when (item.itemId) {
+            R.id.menu_browser_current_category -> {
+                val title = PageTitle(CATEGORY_PREFIX + categoryName, WikiSite(COMMONS_URL))
+
+                handleWebUrl(this, Uri.parse(title.canonicalUri))
+                true
+            }
+
+            R.id.menu_bookmark_current_category -> {
+                categoryName?.let {
+                    viewModel.onBookmarkClick(categoryName = it)
+                }
+                true
+            }
+
+            android.R.id.home -> {
+                onBackPressed()
+                true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
+        menu?.run {
+          val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category)
+            if (bookmarkMenuItem != null) {
+                val icon = if(viewModel.bookmarkState.value){
+                    R.drawable.menu_ic_round_star_filled_24px
+                } else {
+                    R.drawable.menu_ic_round_star_border_24px
+                }
+
+                bookmarkMenuItem.setIcon(icon)
+            }
+        }
+        return super.onPrepareOptionsMenu(menu)
+    }
+
+    /**
+     * This method is called on backPressed of anyFragment in the activity.
+     * If condition is called when mediaDetailFragment is opened.
+     */
+    @Deprecated("This method has been deprecated in favor of using the" +
+            "{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." +
+            "The OnBackPressedDispatcher controls how back button events are dispatched" +
+            "to one or more {@link OnBackPressedCallback} objects.")
+    override fun onBackPressed() {
+        if (supportFragmentManager.backStackEntryCount == 1) {
+            binding.tabLayout.visibility = View.VISIBLE
+            binding.viewPager.visibility = View.VISIBLE
+            binding.mediaContainer.visibility = View.GONE
+        }
+        super.onBackPressed()
+    }
+
+    /**
+     * This method is called on success of API call for Images inside a category.
+     * The viewpager will notified that number of items have changed.
+     */
+    override fun viewPagerNotifyDataSetChanged() {
+        mediaDetails?.notifyDataSetChanged()
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt
new file mode 100644
index 000000000..a50f25669
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt
@@ -0,0 +1,109 @@
+package fr.free.nrw.commons.category
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
+import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * ViewModal for [CategoryDetailsActivity]
+ */
+class CategoryDetailsViewModel(
+    private val bookmarkCategoriesDao: BookmarkCategoriesDao
+) : ViewModel() {
+
+    private val _bookmarkState = MutableStateFlow(false)
+    val bookmarkState = _bookmarkState.asStateFlow()
+
+
+    /**
+     * Used to check if bookmark exists for the given category in DB
+     * based on that bookmark state is updated
+     * @param categoryName
+     */
+    fun onCheckIfBookmarked(categoryName: String) {
+        viewModelScope.launch {
+            val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName)
+            _bookmarkState.update {
+                isBookmarked
+            }
+        }
+    }
+
+    /**
+     * Handles event when bookmark button is clicked from view
+     * based on that category is bookmarked or removed in/from in the DB
+     * and bookmark state is update as well
+     * @param categoryName
+     */
+    fun onBookmarkClick(categoryName: String) {
+        if (_bookmarkState.value) {
+            deleteBookmark(categoryName)
+            _bookmarkState.update {
+                false
+            }
+        } else {
+            addBookmark(categoryName)
+            _bookmarkState.update {
+                true
+            }
+        }
+    }
+
+
+    /**
+     * Add bookmark into DB
+     *
+     * @param categoryName
+     */
+    private fun addBookmark(categoryName: String) {
+        viewModelScope.launch {
+            val categoryItem = BookmarksCategoryModal(
+                categoryName = categoryName
+            )
+
+            bookmarkCategoriesDao.insert(categoryItem)
+        }
+    }
+
+
+    /**
+     * Delete bookmark from DB
+     *
+     * @param categoryName
+     */
+    private fun deleteBookmark(categoryName: String) {
+        viewModelScope.launch {
+            bookmarkCategoriesDao.delete(
+                BookmarksCategoryModal(
+                    categoryName = categoryName
+                )
+            )
+        }
+    }
+
+    /**
+     * View model factory to create [CategoryDetailsViewModel]
+     *
+     * @property bookmarkCategoriesDao
+     * @constructor Create empty View model factory
+     */
+    class ViewModelFactory @Inject constructor(
+        private val bookmarkCategoriesDao: BookmarkCategoriesDao
+    ) : ViewModelProvider.Factory {
+
+        @Suppress("UNCHECKED_CAST")
+        override fun  create(modelClass: Class): T =
+            if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) {
+                CategoryDetailsViewModel(bookmarkCategoriesDao) as T
+            } else {
+                throw IllegalArgumentException("Unknown class name")
+            }
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt
new file mode 100644
index 000000000..22cb19172
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt
@@ -0,0 +1,144 @@
+package fr.free.nrw.commons.category
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.actions.PageEditClient
+import fr.free.nrw.commons.notification.NotificationHelper
+import fr.free.nrw.commons.utils.ViewUtilWrapper
+import io.reactivex.Observable
+import io.reactivex.Single
+import javax.inject.Inject
+import javax.inject.Named
+import timber.log.Timber
+
+
+class CategoryEditHelper @Inject constructor(
+    private val notificationHelper: NotificationHelper,
+    @Named("commons-page-edit") val pageEditClient: PageEditClient,
+    private val viewUtil: ViewUtilWrapper,
+    @Named("username") private val username: String
+) {
+
+    /**
+     * Public interface to edit categories
+     * @param context
+     * @param media
+     * @param categories
+     * @return
+     */
+    fun makeCategoryEdit(
+        context: Context,
+        media: Media,
+        categories: List,
+        wikiText: String
+    ): Single {
+        viewUtil.showShortToast(
+            context,
+            context.getString(R.string.category_edit_helper_make_edit_toast)
+        )
+        return addCategory(media, categories, wikiText)
+            .flatMapSingle { result ->
+                Single.just(showCategoryEditNotification(context, media, result))
+            }
+            .firstOrError()
+    }
+
+    /**
+     * Rebuilds the WikiText with new categories and post it on server
+     *
+     * @param media
+     * @param categories to be added
+     * @return
+     */
+    private fun addCategory(
+        media: Media,
+        categories: List?,
+        wikiText: String
+    ): Observable {
+        Timber.d("thread is category adding %s", Thread.currentThread().name)
+        val summary = "Adding categories"
+        val buffer = StringBuilder()
+
+        // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
+        val wikiTextWithoutCategory: String = when {
+            wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized"))
+            wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category"))
+            else -> ""
+        }
+
+        if (!categories.isNullOrEmpty()) {
+            // If the categories list is empty, when reading the categories of a picture,
+            // the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
+            // So that after selecting some category, "None selected" should be removed from list
+            for (category in categories) {
+                if (category != "None selected" || !wikiText.contains("Uncategorized")) {
+                    buffer.append("[[Category:").append(category).append("]]\n")
+                }
+            }
+            categories.dropWhile {
+                it == "None selected"
+            }
+        } else {
+            buffer.append("{{subst:unc}}")
+        }
+
+        val appendText = wikiTextWithoutCategory + buffer
+        return pageEditClient.edit(media.filename!!, "$appendText\n", summary)
+    }
+
+    private fun showCategoryEditNotification(
+        context: Context,
+        media: Media,
+        result: Boolean
+    ): Boolean {
+        val title: String
+        val message: String
+
+        if (result) {
+            title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
+                    context.getString(R.string.category_edit_helper_show_edit_title_success)
+
+            val categoriesInMessage = StringBuilder()
+            val mediaCategoryList = media.categories
+            for ((index, category) in mediaCategoryList?.withIndex()!!) {
+                categoriesInMessage.append(category)
+                if (index != mediaCategoryList.size - 1) {
+                    categoriesInMessage.append(",")
+                }
+            }
+
+            message = context.resources.getQuantityString(
+                R.plurals.category_edit_helper_show_edit_message_if,
+                mediaCategoryList.size,
+                categoriesInMessage.toString()
+            )
+        } else {
+            title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
+                    context.getString(R.string.category_edit_helper_show_edit_title)
+            message = context.getString(R.string.category_edit_helper_edit_message_else)
+        }
+
+        val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
+        val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
+        notificationHelper.showNotification(
+            context,
+            title,
+            message,
+            NOTIFICATION_EDIT_CATEGORY,
+            browserIntent
+        )
+        return result
+    }
+
+    interface Callback {
+        fun updateCategoryDisplay(categories: List?): Boolean
+    }
+
+    companion object {
+        const val NOTIFICATION_EDIT_CATEGORY = 1
+    }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
deleted file mode 100644
index aa2c87f38..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
+++ /dev/null
@@ -1,209 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.AdapterView;
-
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentTransaction;
-
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.explore.SearchActivity;
-import fr.free.nrw.commons.media.MediaDetailPagerFragment;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-
-/**
- * This activity displays pictures of a particular category
- * Its generic and simply takes the name of category name in its start intent to load all images in
- * a particular category. This activity is currently being used to display a list of featured images,
- * which is nothing but another category on wikimedia commons.
- */
-
-public class CategoryImagesActivity
-        extends NavigationBaseActivity
-        implements FragmentManager.OnBackStackChangedListener,
-                    MediaDetailPagerFragment.MediaDetailProvider,
-                    AdapterView.OnItemClickListener, CategoryImagesCallback {
-
-
-    private FragmentManager supportFragmentManager;
-    private CategoryImagesListFragment categoryImagesListFragment;
-    private MediaDetailPagerFragment mediaDetails;
-
-    /**
-     * This method is called on backPressed of anyFragment in the activity.
-     * We are changing the icon here from back to hamburger icon.
-     */
-    @Override
-    public void onBackPressed() {
-        initDrawer();
-        super.onBackPressed();
-    }
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_category_images);
-        ButterKnife.bind(this);
-
-        // Activity can call methods in the fragment by acquiring a
-        // reference to the Fragment from FragmentManager, using findFragmentById()
-        supportFragmentManager = getSupportFragmentManager();
-        setCategoryImagesFragment();
-        supportFragmentManager.addOnBackStackChangedListener(this);
-        initDrawer();
-        setPageTitle();
-    }
-
-    /**
-     * Gets the categoryName from the intent and initializes the fragment for showing images of that category
-     */
-    private void setCategoryImagesFragment() {
-        categoryImagesListFragment = new CategoryImagesListFragment();
-        String categoryName = getIntent().getStringExtra("categoryName");
-        if (getIntent() != null && categoryName != null) {
-            Bundle arguments = new Bundle();
-            arguments.putString("categoryName", categoryName);
-            categoryImagesListFragment.setArguments(arguments);
-            FragmentTransaction transaction = supportFragmentManager.beginTransaction();
-            transaction
-                    .add(R.id.fragmentContainer, categoryImagesListFragment)
-                    .commit();
-        }
-    }
-
-    /**
-     * Gets the passed title from the intents and displays it as the page title
-     */
-    private void setPageTitle() {
-        if (getIntent() != null && getIntent().getStringExtra("title") != null) {
-            setTitle(getIntent().getStringExtra("title"));
-        }
-    }
-
-    @Override
-    public void onBackStackChanged() {
-    }
-
-    /**
-     * This method is called onClick of media inside category details (CategoryImageListFragment).
-     */
-    @Override
-    public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
-        if (mediaDetails == null || !mediaDetails.isVisible()) {
-            // set isFeaturedImage true for featured images, to include author field on media detail
-            mediaDetails = new MediaDetailPagerFragment(false, true);
-            FragmentManager supportFragmentManager = getSupportFragmentManager();
-            supportFragmentManager
-                    .beginTransaction()
-                    .hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount()))
-                    .add(R.id.fragmentContainer, mediaDetails)
-                    .addToBackStack(null)
-                    .commit();
-            // Reason for using hide, add instead of replace is to maintain scroll position after
-            // coming back to the search activity. See https://github.com/commons-app/apps-android-commons/issues/1631
-            // https://stackoverflow.com/questions/11353075/how-can-i-maintain-fragment-state-when-added-to-the-back-stack/19022550#19022550            supportFragmentManager.executePendingTransactions();
-        }
-        mediaDetails.showImage(i);
-        forceInitBackButton();
-    }
-
-    /**
-     * Consumers should be simply using this method to use this activity.
-     * @param context A Context of the application package implementing this class.
-     * @param title Page title
-     * @param categoryName Name of the category for displaying its images
-     */
-    public static void startYourself(Context context, String title, String categoryName) {
-        Intent intent = new Intent(context, CategoryImagesActivity.class);
-        intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        intent.putExtra("title", title);
-        intent.putExtra("categoryName", categoryName);
-        context.startActivity(intent);
-    }
-
-    /**
-     * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
-     * @param i It is the index of which media object is to be returned which is same as
-     *          current index of viewPager.
-     * @return Media Object
-     */
-    @Override
-    public Media getMediaAtPosition(int i) {
-        if (categoryImagesListFragment.getAdapter() == null) {
-            // not yet ready to return data
-            return null;
-        } else {
-            return (Media) categoryImagesListFragment.getAdapter().getItem(i);
-        }
-    }
-
-    /**
-     * This method is called on success of API call for featured Images.
-     * The viewpager will notified that number of items have changed.
-     */
-    @Override
-    public void viewPagerNotifyDataSetChanged() {
-        if (mediaDetails!=null){
-            mediaDetails.notifyDataSetChanged();
-        }
-    }
-
-    /**
-     * This method is called on from getCount of MediaDetailPagerFragment
-     * The viewpager will contain same number of media items as that of media elements in adapter.
-     * @return Total Media count in the adapter
-     */
-    @Override
-    public int getTotalMediaCount() {
-        if (categoryImagesListFragment.getAdapter() == null) {
-            return 0;
-        }
-        return categoryImagesListFragment.getAdapter().getCount();
-    }
-
-    /**
-     * This method inflates the menu in the toolbar
-     */
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        MenuInflater inflater = getMenuInflater();
-        inflater.inflate(R.menu.menu_search, menu);
-        return super.onCreateOptionsMenu(menu);
-    }
-
-    /**
-     * This method handles the logic on ItemSelect in toolbar menu
-     * Currently only 1 choice is available to open search page of the app
-     */
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-
-        // Handle item selection
-        switch (item.getItemId()) {
-            case R.id.action_search:
-                NavigationBaseActivity.startActivityWithFlags(this, SearchActivity.class);
-                return true;
-            default:
-                return super.onOptionsItemSelected(item);
-        }
-    }
-
-    /**
-     * This method is called when viewPager has reached its end.
-     * Fetches more images using search query and adds it to the gridView and viewpager adapter
-     */
-    @Override
-    public void requestMoreImages() {
-        if (categoryImagesListFragment!=null){
-            categoryImagesListFragment.fetchMoreImagesViewPager();
-        }
-    }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java
deleted file mode 100644
index e93e74fdf..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package fr.free.nrw.commons.category;
-
-/**
- * Callback for notifying the viewpager that the number of items have changed
- * and for requesting more images when the viewpager has been scrolled to its end.
- */
-
-public interface CategoryImagesCallback {
-   void viewPagerNotifyDataSetChanged();
-   void requestMoreImages();
-}
-
-
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
new file mode 100644
index 000000000..9fe811f74
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
@@ -0,0 +1,7 @@
+package fr.free.nrw.commons.category
+
+interface CategoryImagesCallback {
+    fun viewPagerNotifyDataSetChanged()
+
+    fun onMediaClicked(position: Int)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
deleted file mode 100644
index 8280e6b25..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
+++ /dev/null
@@ -1,270 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.annotation.SuppressLint;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AbsListView;
-import android.widget.AdapterView;
-import android.widget.GridView;
-import android.widget.ListAdapter;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import androidx.annotation.Nullable;
-
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import dagger.android.support.DaggerFragment;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.media.MediaClient;
-import fr.free.nrw.commons.utils.NetworkUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
-
-/**
- * Displays images for a particular category with load more on scrolling incorporated
- */
-public class CategoryImagesListFragment extends DaggerFragment {
-
-    private static int TIMEOUT_SECONDS = 15;
-
-    private GridViewAdapter gridAdapter;
-
-    @BindView(R.id.statusMessage)
-    TextView statusTextView;
-    @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
-    @BindView(R.id.categoryImagesList) GridView gridView;
-    @BindView(R.id.parentLayout) RelativeLayout parentLayout;
-    private CompositeDisposable compositeDisposable = new CompositeDisposable();
-    private boolean hasMoreImages = true;
-    private boolean isLoading = true;
-    private String categoryName = null;
-
-    @Inject MediaClient mediaClient;
-    @Inject
-    @Named("default_preferences")
-    JsonKvStore categoryKvStore;
-
-    @Override
-    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
-        View v = inflater.inflate(R.layout.fragment_category_images, container, false);
-        ButterKnife.bind(this, v);
-        return v;
-    }
-
-    @Override
-    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-        gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity());
-        initViews();
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-        compositeDisposable.clear();
-    }
-
-    /**
-     * Initializes the UI elements for the fragment
-     * Setup the grid view to and scroll listener for it
-     */
-    private void initViews() {
-        String categoryName = getArguments().getString("categoryName");
-        if (getArguments() != null && categoryName != null) {
-            this.categoryName = categoryName;
-            resetQueryContinueValues(categoryName);
-            initList();
-            setScrollListener();
-        }
-    }
-
-    /**
-     * Query continue values determine the last page that was loaded for the particular keyword
-     * This method resets those values, so that the results can be queried from the first page itself
-     * @param keyword
-     */
-    private void resetQueryContinueValues(String keyword) {
-        categoryKvStore.remove("query_continue_" + keyword);
-    }
-
-    /**
-     * Checks for internet connection and then initializes the grid view with first 10 images of that category
-     */
-    @SuppressLint("CheckResult")
-    private void initList() {
-        if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
-            handleNoInternet();
-            return;
-        }
-
-        isLoading = true;
-        progressBar.setVisibility(VISIBLE);
-        compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName)
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
-                .subscribe(this::handleSuccess, this::handleError));
-    }
-
-    /**
-     * Handles the UI updates for no internet scenario
-     */
-    private void handleNoInternet() {
-        progressBar.setVisibility(GONE);
-        if (gridAdapter == null || gridAdapter.isEmpty()) {
-            statusTextView.setVisibility(VISIBLE);
-            statusTextView.setText(getString(R.string.no_internet));
-        } else {
-            ViewUtil.showShortSnackbar(parentLayout, R.string.no_internet);
-        }
-    }
-
-    /**
-     * Logs and handles API error scenario
-     * @param throwable
-     */
-    private void handleError(Throwable throwable) {
-        Timber.e(throwable, "Error occurred while loading images inside a category");
-        try{
-            ViewUtil.showShortSnackbar(parentLayout, R.string.error_loading_images);
-            initErrorView();
-        }catch (Exception e){
-            e.printStackTrace();
-        }
-
-    }
-
-    /**
-     * Handles the UI updates for a error scenario
-     */
-    private void initErrorView() {
-        progressBar.setVisibility(GONE);
-        if (gridAdapter == null || gridAdapter.isEmpty()) {
-            statusTextView.setVisibility(VISIBLE);
-            statusTextView.setText(getString(R.string.no_images_found));
-        } else {
-            statusTextView.setVisibility(GONE);
-        }
-    }
-
-    /**
-     * Initializes the adapter with a list of Media objects
-     * @param mediaList List of new Media to be displayed
-     */
-    private void setAdapter(List mediaList) {
-        gridAdapter = new GridViewAdapter(this.getContext(), R.layout.layout_category_images, mediaList);
-        gridView.setAdapter(gridAdapter);
-    }
-
-    /**
-     * Sets the scroll listener for the grid view so that more images are fetched when the user scrolls down
-     * Checks if the category has more images before loading
-     * Also checks whether images are currently being fetched before triggering another request
-     */
-    private void setScrollListener() {
-        gridView.setOnScrollListener(new AbsListView.OnScrollListener() {
-            @Override
-            public void onScrollStateChanged(AbsListView view, int scrollState) {
-            }
-
-            @Override
-            public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
-                if (hasMoreImages && !isLoading && (firstVisibleItem + visibleItemCount + 1 >= totalItemCount)) {
-                    isLoading = true;
-                    fetchMoreImages();
-                }
-                if (!hasMoreImages){
-                    progressBar.setVisibility(GONE);
-                }
-            }
-        });
-    }
-
-    /**
-     * This method is called when viewPager has reached its end.
-     * Fetches more images for the category and adds it to the grid view and viewpager adapter
-     */
-    public void fetchMoreImagesViewPager(){
-        if (hasMoreImages && !isLoading) {
-            isLoading = true;
-            fetchMoreImages();
-        }
-        if (!hasMoreImages){
-            progressBar.setVisibility(GONE);
-        }
-    }
-
-    /**
-     * Fetches more images for the category and adds it to the grid view adapter
-     */
-    @SuppressLint("CheckResult")
-    private void fetchMoreImages() {
-        if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
-            handleNoInternet();
-            return;
-        }
-
-        progressBar.setVisibility(VISIBLE);
-        compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName)
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
-                .subscribe(this::handleSuccess, this::handleError));
-    }
-
-    /**
-     * Handles the success scenario
-     * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter
-     * @param collection List of new Media to be displayed
-     */
-    private void handleSuccess(List collection) {
-        if (collection == null || collection.isEmpty()) {
-            initErrorView();
-            hasMoreImages = false;
-            return;
-        }
-
-        if (gridAdapter == null) {
-            setAdapter(collection);
-        } else {
-            if (gridAdapter.containsAll(collection)) {
-                hasMoreImages = false;
-                return;
-            }
-            gridAdapter.addItems(collection);
-            ((CategoryImagesCallback) getContext()).viewPagerNotifyDataSetChanged();
-        }
-
-        progressBar.setVisibility(GONE);
-        isLoading = false;
-        statusTextView.setVisibility(GONE);
-    }
-
-    /**
-     * It return an instance of gridView adapter which helps in extracting media details
-     * used by the gridView
-     * @return  GridView Adapter
-     */
-    public ListAdapter getAdapter() {
-        return gridAdapter;
-    }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java
deleted file mode 100644
index f03506ed4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-
-import io.reactivex.Observable;
-import retrofit2.http.GET;
-import retrofit2.http.Query;
-
-/**
- * Interface for interacting with Commons category related APIs
- */
-public interface CategoryInterface {
-
-    /**
-     * Searches for categories with the specified name.
-     *
-     * @param filter    The string to be searched
-     * @param itemLimit How many results are returned
-     * @return
-     */
-    @GET("w/api.php?action=query&format=json&formatversion=2"
-            + "&generator=search&gsrnamespace=14")
-    Observable