+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 000000000..79ee123c2
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
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/CHANGELOG.md b/CHANGELOG.md
index 074b74bc2..575aa6a32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,769 @@
# Wikimedia Commons for Android
+## 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
+- Fixed SQLite error in Nearby map
+- Fixed issue with Nearby uploads not being associated with Wikidata p18
+
+## v2.13.0
+- New media details UI, ability to zoom and pan around image
+- Added suggestions for a place that needs photos if user uploads a photo that is near one of them
+- Modifications and fixes to Nearby filters based on user feedback
+- Multiple crash and bug fixes
+
+## v2.12.3
+- Fixed issue with EXIF data, including coords, being removed from uploads
+
+## v2.12.2
+- Fixed crash on startup
+
+## v2.12.1
+- Fixed issue with Nearby loading in wrong location
+- Various crash fixes
+
+## v2.12.0
+- Completed codebase overhaul
+- Added filters for place type and place state to Nearby
+- Switched to using new data client library, aimed at fixing failed uploads
+- Fixed 2FA not working
+- Fixed issues with upload date and deletion notifications
+
+## v2.11.0
+- Refactored upload process, explore/media details, and peer review to use MVP architecture
+- Refactored all AsyncTasks to use RxAndroid
+- Partial migration to Retrofit
+- Allow users to remove EXIF tags from their uploads if desired
+- Multiple crash and bug fixes
+
+## v2.10.2
+- Fixed remaining issues with date image taken
+- Fixed database crash
+
+## v2.10.1
+- Fixed "stuck before category selection screen" bug
+- Fixed notification taps
+- Fixed crash while uploading images
+- Fixed crash while loading contributions
+- Fixed sporadic issue with date image was taken
+
+## v2.10.0
+- Added option to search for places that need pictures in any location
+- Added coordinate check for images submitted via Nearby
+- Added news about ongoing campaigns
+- Easy retry for failed uploads
+- Javadocs for Nearby package
+- Optimized Nearby query for faster loading
+- Allow users to dismiss notifications
+- Various bugfixes for Explore, Notifications and Nearby
+- Fixed uploads getting stuck in "receiving shared content" phase
+- Fixed empty notifications bell icon in main screen
+
+## v2.9.0
+- New main screen UI with Nearby tab
+- New upload UI and flow
+- Multiple uploads
+- Send Log File revamp
+- Fixed issues with wrong "image taken" date
+- Fixed default zoom level in Nearby map
+- Incremented target SDK to 27, with corresponding notification channel fix
+- Removed several redundant libraries to reduce bloat
+
+## v2.8.5
+- Fixed issues with sporadic upload failures due to wrong mimeType
+
+## v2.8.4
+- Hotfix for constant upload crashes for Oreo users
+
+## v2.8.3
+- Fixed issues with session tokens not being cleared in 2FA, which should reduce p18 edit failures as well
+- Fixed crash caused by bug in fetching revert count
+- Fixed crash potentially caused by Traceur library
+
+## v2.8.2
+- Fixed bug with uploads sent via Share being given .jpeg extensions and overwriting files of the same name
+
+## v2.8.1
+- Fixed bug with category edits not being sent to server
+
+## v2.8.0
+- Fixed failed uploads by modifying auth token
+- Fixed crashes during upload by storing file temporarily
+- Added automatic Wikidata p18 edits upon Nearby upload
+- Added Explore feature to browse other Commons images, including featured images
+- Added Achievements feature to see current level and upload stats
+- Added quiz for users with high deletion rates
+- Added first run tutorial for Nearby
+- Various small improvements to ShareActivity UI
+
+## v2.7.2
+- Modified subtext for "automatically get current location" setting to emphasize that it will reveal user's location
+
+## v2.7.1
+- Fixed UI and permission issues with Nearby
+- Fixed issue with My Recent Uploads being empty
+- Fixed blank category issue when uploading directly from Nearby
+- Various crash fixes
+
+## v2.7.0
+- New Nearby Places UI with direct uploads (and associated category suggestions)
+- Added two-factor authentication login
+- Added Notifications activity to display user talk messages
+- Added real-time location tracking in Nearby
+- Added "rate us", "translate", and FB link in About
+- Improvements to UI of navigation drawer, tutorial, media details view, login activity and Settings
+- Added option to nominate picture for deletion in media details view
+- Too many bug and crash fixes to mention!
+
+## v2.6.7
+- Added null checks to prevent frequent crashes in ModificationsSyncAdapter
+
+## v2.6.6
+- Refactored Dagger to fix crashes encountered in production
+- Fixed "?" displaying in description of Nearby places
+- Database-related cleanup and tests
+- Optimized dimens.xml
+- Fixed issue where map opens with incorrect coordinates
+
+## v2.6.5 beta
+- Changed "send log" feature to only send logs to private Google group forum
+- Switched to using Wikimedia maps server instead of Mapbox for privacy reasons
+- Removed event logging from app for privacy reasons
+- Fixed crash caused by rapidly switching from Nearby map to list while loading
+
+## v2.6.4 beta
+- Excluded httpclient and commons-logging to fix release build errors
+- Fixed crashes caused by Fresco and Dagger
+
+## v2.6.3 beta
+- Same as 2.6.2 except with localizations added for Google Code-In
+
+## v2.6.2 beta
+- Reverted temporarily to last stable version while working on crash fix
+
+## v2.6.1 beta
+- Failed attempt to fix crashes in release build with the previous beta release
+
+## v2.6.0 beta
+- Multiple bugfixes for location updates and list/map loading in Nearby
+- Multiple fixes for various crashes and memory leaks
+- Added several unit tests
+- Modified About page to include WMF disclaimer and modified Privacy Policy link to point to our individual privacy policy
+- Added option for users to send logs to developers (has to be manually activated by user)
+- Converted PNGs to WebPs
+- Improved login screen with new design and privacy policy link
+- Improved category display, if a category has an exact name entered, it will be shown first
+- New UI for Nearby list
+- Added product flavors for production and the beta-cluster Wikimedia servers
+- Various improvements to navigation flow and backstack
+
+## v2.5.0 beta
+- Added one-time popup for beta users to provide feedback on IEG renewal proposal
+- Added link to Commons policies in ShareActivity
+- Various string fixes
+- Switched to using vector icons for map markers
+- Added filter for irrelevant categories
+- Fixed various crashes
+- Incremented target SDK to 25
+- Improved appearance of navigation drawer
+- Replaced proprietary app image in tutorial with one that isn't Telegram
+- Fixed camera issue with FileProvider
+- Added RxJava library, migrated to Java 8
+- Various code and continuous integration optimizations
+
+## v2.4.2 beta
+- Added option to launch tutorial again from nav drawer
+- Added marker for current location in Nearby map
+- Fixed various strings
+- Added check for location permissions when launching Nearby
+- Temporary fix for API 25 camera crash
+- App should now display accurate upload count
+- Updated Gradle from 3.3 to 4.0
+
+## v2.4.1 beta
+- Fixed crash with uploading multiple photos
+- Fixed memory leaks
+- Fixed issues with Nearby places list and map
+
+## v2.4
+- Fixed memory issue with loading contributions on main screen
+- Deleted images don't show up on contributions list
+- Added Fresco library for image loading and LeakCanary for memory profiling
+- Added navigation drawer and overhauled action bar
+- Added logout functionality
+- Fixed various issues with map of Nearby places
+
+## v2.3 beta
+- Add map of Nearby places
+- Add overlay dialog when a Nearby place is tapped
+- Set default number of uploads to display in Main activity as 100, and add option in Settings to change it
+- Detect when 2FA is used for login and display message
+- Display date uploaded and image coordinates in image details page
+- Display message when GPS is turned off, and when no Nearby items are found
+
+## v2.2.2
+- Hotfix for Nearby localization issue
+
+## v2.2.1
+- Hotfix for Settings crash
+
+## v2.2 beta (will not be released to Production due to bugs with Settings)
+- Revamped Nearby to query Wikidata by default instead of Wiki Needs Pictures
+- Added action bar to About screen
+- Fixed crash related to fragment transaction state loss
+- Moved Feedback menu item below Settings
+- Various code optimizations and refactoring
+
+## v2.1
+- Added beta opt in link to Settings
+- Added Codacy and Butterknife support
+- Added Light theme for day/outdoor use
+- Added Material icons
+- Reordered overflow menu items
+- Added credits to About page
+- Fixed lint issues
+- Fixed various crashes
+
+## v2.0.2
+- Make "View in browser" direct to mobile website
+
+## v2.0.1
+- Disabled minify again (reenabling test failed)
+- Hotfix for ShareAction bug
+
+## v2.0
+- Modified Share button in media details fragment to allow user to choose different apps
+- Added CC-BY 4.0 and CC-BY-SA 4.0 to license options
+- Added selection pane for licenses on title/desc screen
+- Switched to using material design for login form fields
+- Added Checkstyle support
+- Reenabled minify in Gradle
+- Other minor code optimizations
+
+## v1.44
+- Attempted fix for GPS suggestions issue
+
+## v1.43
+- Added translations for multiple languages
+- Minor code optimization
+
+## v1.42
+- Fixed language mappings; successful translatewiki integration
+- Various translations added
+
+## v1.41
+- Bumped min SDK and removed escaped characters for translatewiki.net integration
+- Added check for whether file already exists on Commons
+
+## v1.40
+- Added new pages to tutorial
+
+## v1.39
+- Fix for Korean translations crash
+- Various minor fixes
+
+## v1.38
+- Added filter for suggested categories containing years (other than current or previous year)
+- Attempted fix for issues with categories not being saved
+
+## v1.37
+- Added category suggestions based on entered title
+
+## v1.36
+- Fixed Ukranian translations
+
+## v1.35
+- Fixed issues with GPS category suggestions
+
+## v1.34
+- Added button to use previous title/desc
+
+## v1.33
+- Fixed crash when back button pressed before Nearby list is loaded
+- Fixed crash when Nearby list is loaded without network connection
+- Added no args constructor for GPS category suggestions
+
+## v1.32
+- Use Quadtree source instead of JAR, for F-Droid compatibility
+- Fixed GPS extractor not being called
+
+## v1.31
+- Fixed bug with geolocation category suggestions not being displayed
+- Fixed bug with (0,0) being recorded as image location occasionally
+
+## v1.30
+- Fixed {{Location|null}} template bug
+
+## v1.29
+- Added new icons to Nearby
+- Added link to website on About
+
+## v1.28
+- Added geocoding template from GPS data stored in image
+- Fixed bug with doubled list view in Nearby
+- Further attempts to reduce overwrites
+
+## v1.27
+- New feature: List of nearby places without photos
+
+## v1.26
+- Fixed bug with overwriting files when multiple images selected
+
+## v1.25
+- Added in-app signup feature for new users
+- Fixed crash when reading GPS coordinates
+
+## v1.24
+- Moved from bits/event.gif to wikimedia/beacon
+- Fixed issue with needing to tap gallery again after giving permissions
+
+## v1.23
+- Added warning if image is submitted without categories
+- Added check if back button is pressed at category selection screen
+
+## v1.22
+- Fixed various crashes
+- Crash reports now go to private mailing list to protect user info
+
+## v1.21
+- Fixed Google Photos multiple share crash
+
+## v1.20
+- Hotfix for data=null crash
+
+## v1.19
+- Fixed adapter crash
+- Attempt at fixing Google Photos crash
+
+## v1.18
+- Fixed various crashes
+- Fixed camera and gallery for API 23
+
+## v1.17
+- Fixed various crashes
+- Fixed 'Desc/license/categories empty' bug
+
+## v1.16
+- Fixed various crashes
+- Reduced APK size
+- Fixed 'waiting for first sync' bug
+
+## v1.15
+- Added material design logo
+
+## v1.14
+- Migrated to Gradle
+- Fixed API 23 permission crash
+- Fixed "Template:According to EXIF data" analyzing EXIF data incorrectly
+
+## v1.13
+- Fixed prettyLicense and mediaUri crashes
+
+## v1.12
+- Further bug fixes for Polish language
+- Added Javadocs
+
+## v1.11
+- Bugfix for Polish language crash
+
+## v1.10
+- Bugfix for null location crash
+
+## v1.9
+- Bugfix for null pages array crash
+- New feature: Added option to use GPS to find nearby categories if picture is not geotagged
+
+## v1.8
+- New feature: Improved category search function (not limited to prefix search now)
+
+## v1.7
+- Fixed bug with uploading images in Marshmallow
+- Fixed links in About page
+
+## v1.6
+- Bugfix for invalid images
+
+## v1.5
+- Caches area and associated categories
+- Increased search radius for nearby categories
+
+## v1.4
+- New feature: Suggests nearby Commons categories
+
+## v1.3
+- Removed 'send usage reports' setting
+- Fixed package naming issue
+- Added 'sign up' button
+- Removed unused 'campaigns' shortcut
+
+## v1.0 beta 11
+- New Launcher Icon
+- Fix bug with licensing templates
+- i18n updates
+
## v1.0 beta 10
- Successfully reached double digit beta number
- Honeycomb fixes
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..d8f293a25
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,38 @@
+Thanks for considering to contribute to this project! A few guidelines for
+people who want to contribute their code to this software are documented in
+[this project's Wiki](https://github.com/commons-app/apps-android-commons/wiki/Contributing-Guidelines).
+If you're not sure where to start head on to [this wiki page](https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome!).
+
+Here's a gist of the guidelines,
+
+1. Make separate commits for logically separate changes
+
+2. Describe your changes well in the commit message
+
+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.
+
+1. Write Javadocs
+
+ We require contributors to include Javadocs for all new methods and classes
+ submitted via PRs (after 1 May 2018). This is aimed at making it easier for
+ new contributors to dive into our codebase, especially those who are new to
+ Android development. A few things to note:
+
+ - This should not replace the need for code that is easily-readable in
+ and of itself
+ - Please make sure that your Javadocs are reasonably descriptive, not just
+ a copy of the method name
+ - Please do not use `@author` tags - we aim for collective code ownership,
+ and if needed, Git allows us to see who wrote something without needing
+ to add these tags (`git blame`)
+
+2. Write tests for your code (if possible)
+
+3. Make sure the Wiki pages don't become stale by updating them (if needed)
+
+### Further reading
+
+* [Importance of good commit messages](https://blog.oozou.com/commit-messages-matter-60309983c227?gi=c550a10d0f67)
diff --git a/CREDITS b/CREDITS
index c3661da74..3fe6b00d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -4,7 +4,1005 @@ their contribution to the product.
* Brion Vibber
* Niklas Laxström
+* Prateek Saxena
* Sage Ross
* Siebrand Mazeland
* Translatewiki.net Translators https://translatewiki.net/wiki/Special:ListUsers/translator
* Yuvi Panda
+* Nicolas Raoul
+* Stephen Niedzielski
+* Josephine Lim
+* Adam Jones
+* Yusuke Matsubara
+* Tobias Schönberg
+* Brian MacIntosh
+* Veyndan Stuart
+* Vivek Maskara
+* Neslihan Turan
+* Wikimedia Czech Republic (host for the Prague pre-hackathon 2017)
+* Vojtěch Dostál
+* Dinu Kumarasiri
+* Dmitry Brant
+* Adam Shorland
+* John Lubbock
+* Mikel Pascual
+* Jan Piotrowski
+* Bruke Mekuria Mulugeta
+* Paul Hawke
+* Vishan Seru
+* Abhishek Poonia
+* Ayushi Negi
+* Harisanker Pradeep
+* Hassan Ismaeel
+* Jatin Rao
+* Meghna Gupta
+* S Balakrishnan
+* Suchit Kar
+* Tanvi Dadu
+* Ujjwal Agrawal
+* Mansi Agarwal
+* Siddharth Vaish
+* Ashish Kumar
+* Ilgaz Er
+* Alicia Bendz
+* Kaartic Sivaraam
+* Vanshika Arora
+* Seán Mac Gillicuddy
+
+3rd party open source libraries used:
+* Butterknife
+* GSON
+* Timber
+
+3rd party open source apps from which significant code has been reused:
+* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia
+
+===========================================================================
+
+The Wikimedia Commons Android app uses portions of MapBox.
+
+mapbox-gl-native copyright (c) 2014-2018 Mapbox.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+===========================================================================
+
+Mapbox GL uses portions of Android Gesture Detectors Framework.
+
+Copyright (c) 2012, Almer Thie
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+===========================================================================
+
+Mapbox GL uses portions of Android Support Library.
+
+Copyright (c) 2005-2013, The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+===========================================================================
+
+Mapbox GL uses portions of Boost.
+
+Distributed under the Boost Software License, Version 1.0.
+
+http://www.boost.org/LICENSE_1_0.txt
+
+===========================================================================
+
+Mapbox GL uses portions of Clipper.
+
+Author : Angus Johnson
+Version : 6.1.3a
+Date : 22 January 2014
+Website : http://www.angusj.com
+Copyright : Angus Johnson 2010-2014
+
+License:
+Use, modification & distribution is subject to Boost Software License Ver 1.
+http://www.boost.org/LICENSE_1_0.txt
+
+Attributions:
+The code in this library is an extension of Bala Vatti's clipping algorithm:
+"A generic solution to polygon clipping"
+Communications of the ACM, Vol 35, Issue 7 (July 1992) pp 56-63.
+http://portal.acm.org/citation.cfm?id=129906
+
+Computer graphics and geometric modeling: implementation and algorithms
+By Max K. Agoston
+Springer; 1 edition (January 4, 2005)
+http://books.google.com/books?q=vatti+clipping+agoston
+
+See also:
+"Polygon Offsetting by Computing Winding Numbers"
+Paper no. DETC2005-85513 pp. 565-575
+ASME 2005 International Design Engineering Technical Conferences
+and Computers and Information in Engineering Conference (IDETC/CIE2005)
+September 24-28, 2005 , Long Beach, California, USA
+http://www.me.berkeley.edu/~mcmains/pubs/DAC05OffsetPolygon.pdf
+
+===========================================================================
+
+Mapbox GL uses portions of BugshotKit.
+
+The MIT License (MIT)
+
+Copyright (c) 2014 marcoarment
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of CSS Color Parser.
+
+(c) Dean McNamee , 2012.
+C++ port by Konstantin Käfer , 2014.
+
+https://github.com/deanm/css-color-parser-js
+https://github.com/kkaefer/css-color-parser-cpp
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of GLFW.
+
+Copyright (c) 2002-2006 Marcus Geelnard
+Copyright (c) 2006-2010 Camilla Berglund
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would
+ be appreciated but is not required.
+
+2. Altered source versions must be plainly marked as such, and must not
+ be misrepresented as being the original software.
+
+3. This notice may not be removed or altered from any source
+ distribution.
+
+===========================================================================
+
+Mapbox GL uses portions of libc++.
+
+The libc++ library is dual licensed under both the University of Illinois
+"BSD-Like" license and the MIT license. As a user of this code you may choose
+to use it under either license. As a contributor, you agree to allow your code
+to be used under both.
+
+Full text of the relevant licenses is included below.
+
+====
+
+University of Illinois/NCSA
+Open Source License
+
+Copyright (c) 2009-2015 by the contributors listed in CREDITS.TXT
+
+All rights reserved.
+
+Developed by:
+
+ LLVM Team
+
+ University of Illinois at Urbana-Champaign
+
+ http://llvm.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal with
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+* Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimers.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimers in the
+ documentation and/or other materials provided with the distribution.
+
+* Neither the names of the LLVM Team, University of Illinois at
+ Urbana-Champaign, nor the names of its contributors may be used to
+ endorse or promote products derived from this Software without specific
+ prior written permission.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE
+SOFTWARE.
+
+====
+
+Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of libcurl.
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright (c) 1996 - 2015, Daniel Stenberg, .
+
+All rights reserved.
+
+Permission to use, copy, modify, and distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright
+notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
+OR OTHER DEALINGS IN THE SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall not
+be used in advertising or otherwise to promote the sale, use or other dealings
+in this Software without prior written authorization of the copyright holder.
+
+===========================================================================
+
+Mapbox GL uses portions of libjpeg-turbo.
+
+This software is based in part on the work of the Independent JPEG Group.
+
+Copyright (C)2009-2015 D. R. Commander. All Rights Reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+- Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+- Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+- Neither the name of the libjpeg-turbo Project nor the names of its
+ contributors may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS",
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+TurboJPEG/LJT: this implements the TurboJPEG API using libjpeg or libjpeg-turbo
+
+===========================================================================
+
+Mapbox GL uses portions of libpng.
+
+This copy of the libpng notices is provided for your convenience. In case of
+any discrepancy between this copy and the notices in the file png.h that is
+included in the libpng distribution, the latter shall prevail.
+
+COPYRIGHT NOTICE, DISCLAIMER, and LICENSE:
+
+If you modify libpng you may insert additional notices immediately following
+this sentence.
+
+This code is released under the libpng license.
+
+libpng versions 1.0.7, July 1, 2000, through 1.6.18, July 23, 2015, are
+Copyright (c) 2000-2002, 2004, 2006-2015 Glenn Randers-Pehrson, and are
+distributed according to the same disclaimer and license as libpng-1.0.6
+with the following individuals added to the list of Contributing Authors:
+
+ Simon-Pierre Cadieux
+ Eric S. Raymond
+ Mans Rullgard
+ Cosmin Truta
+ Gilles Vollant
+ James Yu
+
+and with the following additions to the disclaimer:
+
+ There is no warranty against interference with your enjoyment of the
+ library or against infringement. There is no warranty that our
+ efforts or the library will fulfill any of your particular purposes
+ or needs. This library is provided with all faults, and the entire
+ risk of satisfactory quality, performance, accuracy, and effort is with
+ the user.
+
+libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are
+Copyright (c) 1998-2000 Glenn Randers-Pehrson, and are distributed according
+to the same disclaimer and license as libpng-0.96, with the following
+individuals added to the list of Contributing Authors:
+
+ Tom Lane
+ Glenn Randers-Pehrson
+ Willem van Schaik
+
+libpng versions 0.89, June 1996, through 0.96, May 1997, are
+Copyright (c) 1996-1997 Andreas Dilger, and are
+distributed according to the same disclaimer and license as libpng-0.88,
+with the following individuals added to the list of Contributing Authors:
+
+ John Bowler
+ Kevin Bracey
+ Sam Bushell
+ Magnus Holmgren
+ Greg Roelofs
+ Tom Tanner
+
+libpng versions 0.5, May 1995, through 0.88, January 1996, are
+Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc.
+
+For the purposes of this copyright and license, "Contributing Authors"
+is defined as the following set of individuals:
+
+ Andreas Dilger
+ Dave Martindale
+ Guy Eric Schalnat
+ Paul Schmidt
+ Tim Wegner
+
+The PNG Reference Library is supplied "AS IS". The Contributing Authors
+and Group 42, Inc. disclaim all warranties, expressed or implied,
+including, without limitation, the warranties of merchantability and of
+fitness for any purpose. The Contributing Authors and Group 42, Inc.
+assume no liability for direct, indirect, incidental, special, exemplary,
+or consequential damages, which may result from the use of the PNG
+Reference Library, even if advised of the possibility of such damage.
+
+Permission is hereby granted to use, copy, modify, and distribute this
+source code, or portions hereof, for any purpose, without fee, subject
+to the following restrictions:
+
+1. The origin of this source code must not be misrepresented.
+
+2. Altered versions must be plainly marked as such and must not
+ be misrepresented as being the original source.
+
+3. This Copyright notice may not be removed or altered from any
+ source or altered source distribution.
+
+The Contributing Authors and Group 42, Inc. specifically permit, without
+fee, and encourage the use of this source code as a component to
+supporting the PNG file format in commercial products. If you use this
+source code in a product, acknowledgment is not required but would be
+appreciated.
+
+===========================================================================
+
+Mapbox GL uses portions of libuv.
+
+libuv is part of the Node project: http://nodejs.org/
+libuv may be distributed alone under Node's license:
+
+====
+
+Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+
+====
+
+This license applies to all parts of libuv that are not externally
+maintained libraries.
+
+The externally maintained libraries used by libuv are:
+
+- tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license.
+
+- inet_pton and inet_ntop implementations, contained in src/inet.c, are
+ copyright the Internet Systems Consortium, Inc., and licensed under the ISC
+ license.
+
+- stdint-msvc2008.h (from msinttypes), copyright Alexander Chemeris. Three
+ clause BSD license.
+
+- pthread-fixes.h, pthread-fixes.c, copyright Google Inc. and Sony Mobile
+ Communications AB. Three clause BSD license.
+
+- android-ifaddrs.h, android-ifaddrs.c, copyright Berkeley Software Design
+ Inc, Kenneth MacKay and Emergya (Cloud4all, FP7/2007-2013, grant agreement
+ n° 289016). Three clause BSD license.
+
+===========================================================================
+
+Mapbox GL uses portions of libzip.
+
+Copyright (C) 1999-2014 Dieter Baron and Thomas Klausner
+
+The authors can be contacted at
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+3. The names of the authors may not be used to endorse or promote
+ products derived from this software without specific prior
+ written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
+IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+===========================================================================
+
+Mapbox GL uses portions of LOST.
+
+Copyright (c) 2014 Mapzen
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+===========================================================================
+
+Mapbox GL uses portions of the Mapbox iOS SDK, which was derived from the
+Route-Me open source project, including the Alpstein fork of it.
+
+The Route-Me license appears below.
+
+Copyright (c) 2008-2013, Route-Me Contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+===========================================================================
+
+Mapbox GL uses portions of nunicode.
+
+Copyright (c) 2013 Aleksey Tulinov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of OkHTTP.
+
+Copyright 2014 Square, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+===========================================================================
+
+Mapbox GL uses portions of OpenSSL.
+
+LICENSE ISSUES
+==============
+
+The OpenSSL toolkit stays under a dual license, i.e. both the conditions of
+the OpenSSL License and the original SSLeay license apply to the toolkit.
+See below for the actual license texts. Actually both licenses are BSD-style
+Open Source licenses. In case of any license issues related to OpenSSL
+please contact openssl-core@openssl.org.
+
+OpenSSL License
+---------------
+
+Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+3. All advertising materials mentioning features or use of this
+ software must display the following acknowledgment:
+ "This product includes software developed by the OpenSSL Project
+ for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
+
+4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to
+ endorse or promote products derived from this software without
+ prior written permission. For written permission, please contact
+ openssl-core@openssl.org.
+
+5. Products derived from this software may not be called "OpenSSL"
+ nor may "OpenSSL" appear in their names without prior written
+ permission of the OpenSSL Project.
+
+6. Redistributions of any form whatsoever must retain the following
+ acknowledgment:
+ "This product includes software developed by the OpenSSL Project
+ for use in the OpenSSL Toolkit (http://www.openssl.org/)"
+
+THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY
+EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR
+ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+OF THE POSSIBILITY OF SUCH DAMAGE.
+
+This product includes cryptographic software written by Eric Young
+(eay@cryptsoft.com). This product includes software written by Tim
+Hudson (tjh@cryptsoft.com).
+
+Original SSLeay License
+-----------------------
+
+Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com)
+All rights reserved.
+
+This package is an SSL implementation written
+by Eric Young (eay@cryptsoft.com).
+The implementation was written so as to conform with Netscapes SSL.
+
+This library is free for commercial and non-commercial use as long as
+The following conditions are aheared to. The following conditions
+apply to all code found in this distribution, be it the RC4, RSA,
+lhash, DES, etc., code; not just the SSL code. The SSL documentation
+included with this distribution is covered by the same copyright terms
+except that the holder is Tim Hudson (tjh@cryptsoft.com).
+
+Copyright remains Eric Young's, and as such any Copyright notices in
+the code are not to be removed.
+If this package is used in a product, Eric Young should be given attribution
+as the author of the parts of the library used.
+This can be in the form of a textual message at program startup or
+in documentation (online or textual) provided with the package.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+3. All advertising materials mentioning features or use of this software
+ must display the following acknowledgement:
+ "This product includes cryptographic software written by
+ Eric Young (eay@cryptsoft.com)"
+ The word 'cryptographic' can be left out if the rouines from the library
+ being used are not cryptographic related :-).
+4. If you include any Windows specific code (or a derivative thereof) from
+ the apps directory (application code) you must include an acknowledgement:
+ "This product includes software written by Tim Hudson (tjh@cryptsoft.com)"
+
+THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+
+The licence and distribution terms for any publically available version or
+derivative of this code cannot be changed. i.e. this code cannot simply be
+copied and put under another distribution licence
+[including the GNU Public Licence.]
+
+===========================================================================
+
+Mapbox GL uses portions of RapidJSON.
+
+Tencent is pleased to support the open source community by making RapidJSON
+available.
+
+Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights
+reserved.
+
+If you have downloaded a copy of the RapidJSON binary from Tencent, please note
+that the RapidJSON binary is licensed under the MIT License. If you have
+downloaded a copy of the RapidJSON source code from Tencent, please note that
+RapidJSON source code is licensed under the MIT License, except for the third-
+party components listed below which are subject to different license terms.
+Your integration of RapidJSON into your own projects may require compliance with
+the MIT License, as well as the other licenses applicable to the third-party
+components included within RapidJSON. To avoid the problematic JSON license in
+your own projects, it's sufficient to exclude the bin/jsonchecker/ directory, as
+it's the only code under the JSON license. A copy of the MIT License is included
+in this file.
+
+Other dependencies and licenses:
+
+Open Source Software Licensed Under the BSD License:
+--------------------------------------------------------------------
+
+The msinttypes r29
+Copyright (c) 2006-2013 Alexander Chemeris
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+* Neither the name of copyright holder nor the names of its contributors may be
+ used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Open Source Software Licensed Under the JSON License:
+--------------------------------------------------------------------
+
+json.org
+Copyright (c) 2002 JSON.org
+All Rights Reserved.
+
+JSON_checker
+Copyright (c) 2002 JSON.org
+All Rights Reserved.
+
+Terms of the JSON License:
+---------------------------------------------------
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+The Software shall be used for Good, not Evil.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+Terms of the MIT License:
+--------------------------------------------------------------------
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of Reachability.
+
+Copyright (c) 2011, Tony Million.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+===========================================================================
+
+Mapbox GL uses portions of SQLite.
+
+2001 September 15
+
+The author disclaims copyright to this source code. In place of
+a legal notice, here is a blessing:
+
+ May you do good and not evil.
+ May you find forgiveness for yourself and forgive others.
+ May you share freely, never taking more than you give.
+
+===========================================================================
+
+Mapbox GL uses portions of SVPulsingAnnotationView.
+
+Copyright (c) 2013, Sam Vermette
+
+Permission to use, copy, modify, and/or distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright notice
+and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
+
+===========================================================================
+
+Mapbox GL uses portions of zlib.
+
+Acknowledgments:
+
+The deflate format used by zlib was defined by Phil Katz. The deflate and
+zlib specifications were written by L. Peter Deutsch. Thanks to all the
+people who reported problems and suggested various improvements in zlib; they
+are too numerous to cite here.
+
+Copyright notice:
+
+(C) 1995-2013 Jean-loup Gailly and Mark Adler
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
+
+ Jean-loup Gailly Mark Adler
+ jloup@gzip.org madler@alumni.caltech.edu
+
+===========================================================================
+
+Mapbox GL uses portions of Realm Objective-C.
+
+Copyright 2015 Realm Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
diff --git a/README.md b/README.md
index 5b83bd247..37f1a7872 100644
--- a/README.md
+++ b/README.md
@@ -1,99 +1,58 @@
-# The Wikimedia Commons Android App #
+# Wikimedia Commons Android app
+
+[](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)
-## Build Requirements ##
+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].
-1. [Android SDK][1] (Level 15)
-2. [Maven][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! :-)
-## Build Instructions ##
+
+
+
+
-1. Set the environment variable `ANDROID_HOME` to be the path to your Android SDK
-2. Run `mvn install` to build
-3. Run `mvn android:deploy` to deploy to a device
-4. There is no step 4
+## Documentation
-**Note**: Currently uses a bunch of dependencies that are staged at `yuvi.in/blog/maven`. Will be migrated to either [Maven Central][4] or a Wikimedia staging server soon.
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
-## Set Up IntelliJ for Commons Android App Development ##
+* [User Documentation][5]
+* [Contributor Documentation][6]
+ * [Volunteers Welcome!][7]
+* [Developer Documentation][8]
+ * [Libraries Used][9]
-### Import and Compile Commons Android App ##
+## Contributors ##
-[Download IntelliJ][6]
+Thank you all for your work!
-1. Clone the repository.
-2. Open IntelliJ.
-3. Import Project:
- File -> Import Project
- or
- Select 'Import Project' from the Quick Start menu
-4. Navigate to the folder with the cloned repository and press 'OK'.
-5. Select 'Import Project from external model' -> 'Maven' and press 'Next'.
-6. Make sure 'Search for projects recursively' and 'Import Maven projects automatically' are checked. Select 'Next'.
-7. This section needs no modification. Select 'Next'.
-8. This section needs no modification. Select 'Next'.
-9. Make sure the 'Android SDK home path' points to the 'android-sdk' folder. If the dropdown next to 'Java SDK' is empty, hit the '+' button avobe the sidebar and select 'JDK'. Navigate to your jdk folder, select it, and hit 'OK'. Now select the newly added JDK and hit 'Next'.
-10. This section needs no modifications. Select 'Next'.
-11. Select 'Finish'.
-12. After the program opens select 'Make project' - there should be errors.
-13. Near the top of the file that is opened up, one of the offending lines should be "import android.support.v4.app.FragmentActivity;" - put your cursor on that line and hit 'alt'/'option'+'enter' to bring up the AutoFix dialog. Select the 'compatibility' option.
-14. Select 'Make project' again. It should compile successfully.
+| [ 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) |
+| :---: | :---: | :---: | :---: | :---: |
+| [ 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).
## License ##
-This software is licensed under the [Apache License][5].
-
-## Bugs? ##
-
-This software has no bugs. You can dispute this statement at [bugzilla][3]
-
-## Code Structure ##
-
-Key breakdowns:
-
-Activities started within the UI:
-* ContributionsActivity (ContributionsListFragment, MediaDetailPagerFragment, MediaDetailFragment) - main "my uploads" list and detail view
-* LoginActivity - login screen when setting up an account
-* SettingsActivity - settings screen
-* AboutActivity - about screen
-
-Activities receiving intents:
-* ShareActivity (SingleUploadFragment, CategorizationFragment) - handles receiving a file from another app, accepting a title/desc, and slating it for upload
-* MultipleShareActivity (MultipleUploadListFragment, CategorizationFragment) - handles receiving a batch of multiple files from another app, accepting a title/desc, and slating them for upload
-
-Services:
-* WikiAccountAuthenticatorService - authentication service
-* UploadService - performs actual file uploads in background
-* ContributionsSyncService - polls for updated contributions list from server
-* ModificationsSyncService - pushes category additions up to server
-
-Content providers:
-* ContributionsContentProvider - private storage for local copy of user's contribution list
-* ModificationsContentProvider - private storage for pending category and template modifications
-* CategoryContentProvider - private storage for recently used categories
+This software is open source, licensed under the [Apache License 2.0][10].
-## On-Device Storage ##
+[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?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
-Account credentials are encapsulated in an account provider. Currently only one Wikimedia Commons account is supported at a time. (Question: what is the actual storage for credentials?)
+[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
-Preferences are stored in Android's SharedPreferences.
-
-Information about past and pending uploads is stored in the Contributions content provider, which uses an SQLite database on the backend.
-
-A list of recently-used categories is stored in the Categories content provider, which uses an SQLite database on the backend.
-
-Captured files are not currently stored within the app, but are passed by content: or file: URI from other apps.
-
-Thumbnail images are not currently cached. (?)
-
-
-
-
-
-
-[1]: https://developer.android.com/sdk/index.html
-[2]: https://maven.apache.org/
-[3]: https://bugzilla.wikimedia.org/enter_bug.cgi?product=Commons%20App
-[4]: http://search.maven.org/
-[5]: https://www.apache.org/licenses/LICENSE-2.0
-[6]: http://www.jetbrains.com/idea/download/index.html
+[10]: https://www.apache.org/licenses/LICENSE-2.0
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
new file mode 100644
index 000000000..21c584ba9
--- /dev/null
+++ b/app/proguard-rules.txt
@@ -0,0 +1,100 @@
+-dontobfuscate
+-ignorewarnings
+
+-dontnote **
+-dontwarn net.bytebuddy.**
+-dontwarn org.mockito.**
+
+# --- Apache ---
+-keep class org.apache.http.** { *; }
+-dontwarn org.apache.**
+# --- /Apache ---
+
+# --- Butter Knife ---
+# Finder.castParam() is stripped when not needed and ProGuard notes it
+# unnecessarily. When castParam() is needed, it's not stripped. e.g.:
+#
+# @OnItemSelected(value = R.id.history_entry_list)
+# void foo(ListView bar) {
+# L.d("baz");
+# }
+
+-dontnote butterknife.internal.**
+# --- /Butter Knife ---
+
+# --- Retrofit2 ---
+# Platform calls Class.forName on types which do not exist on Android to determine platform.
+-dontnote retrofit2.Platform
+# Platform used when running on Java 8 VMs. Will not be used at runtime.
+-dontwarn retrofit2.Platform$Java8
+# Retain generic type information for use by reflection by converters and adapters.
+-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 ---
+-dontwarn okhttp3.**
+-dontwarn okio.**
+# --- /OkHttp + Okio ---
+
+# --- Gson ---
+# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
+
+# Gson uses generic type information stored in a class file when working with fields. Proguard
+# removes such information by default, so configure it to keep all of it.
+-keepattributes Signature
+
+# For using GSON @Expose annotation
+-keepattributes *Annotation*
+
+# Gson specific classes
+-dontwarn sun.misc.**
+#-keep class com.google.gson.stream.** { *; }
+
+# 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
+-keep class * implements com.google.gson.JsonSerializer
+-keep class * implements com.google.gson.JsonDeserializer
+# --- /Gson ---
+
+
+# --- /logback ---
+
+-keep class ch.qos.** { *; }
+-keep class org.slf4j.** { *; }
+-keepattributes *Annotation*
+
+-dontwarn ch.qos.logback.core.net.*
+
+# --- /acra ---
+-keep class org.acra.** { *; }
+-keepattributes SourceFile,LineNumberTable
+-keepattributes *Annotation*
+
+# --- /recycler view ---
+-keep class androidx.recyclerview.widget.RecyclerView {
+ public androidx.recyclerview.widget.RecyclerView$ViewHolder findViewHolderForPosition(int);
+}
+# --- Parcelable ---
+-keepclassmembers class * implements android.os.Parcelable {
+ static ** CREATOR;
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
new file mode 100644
index 000000000..50dfe8e7f
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
@@ -0,0 +1,149 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions
+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.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
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+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))
+ }
+
+ @After
+ fun cleanUp() {
+ Intents.release()
+ }
+
+ @Test
+ fun testBuildNumber() {
+ 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),
+ ),
+ )
+ }
+
+ @Test
+ fun testLaunchFacebook() {
+ Espresso.onView(ViewMatchers.withId(R.id.facebook_launch_icon)).perform(ViewActions.click())
+ 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 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),
+ ),
+ )
+ }
+
+ @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.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),
+ ),
+ )
+ }
+
+ @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),
+ ),
+ )
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
new file mode 100644
index 000000000..9bfc9321b
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
@@ -0,0 +1,69 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation.ActivityResult
+import android.content.Intent
+import androidx.test.espresso.Espresso
+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.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.uiautomator.UiDevice
+import fr.free.nrw.commons.auth.LoginActivity
+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
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LoginActivityTest {
+ @get:Rule
+ var activityRule = ActivityTestRule(LoginActivity::class.java)
+
+ private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
+ Intents.init()
+ UITestHelper.skipWelcome()
+ intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null))
+ }
+
+ @After
+ fun cleanUp() {
+ Intents.release()
+ }
+
+ @Test
+ fun testForgotPassword() {
+ 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)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
new file mode 100644
index 000000000..3d2fc9e48
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
@@ -0,0 +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.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<*> = 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 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)
+ }
+
+ @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/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/ReviewActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
new file mode 100644
index 000000000..3f6487e47
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
@@ -0,0 +1,19 @@
+package fr.free.nrw.commons
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import fr.free.nrw.commons.review.ReviewActivity
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ReviewActivityTest {
+ @get:Rule
+ var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java)
+
+ @Test
+ fun orientationChange() {
+ UITestHelper.changeOrientation(activityRule)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
new file mode 100644
index 000000000..69ce412b9
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
@@ -0,0 +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.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
+
+@RunWith(AndroidJUnit4::class)
+class SearchActivityTest {
+ @get:Rule
+ var activityRule = ActivityTestRule(SearchActivity::class.java)
+
+ private val device: UiDevice =
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
+ }
+
+ @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
new file mode 100644
index 000000000..c5a91cd56
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
@@ -0,0 +1,70 @@
+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.assertion.ViewAssertions.matches
+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.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.SettingsActivity
+import org.hamcrest.CoreMatchers.allOf
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SettingsActivityTest {
+ private lateinit var defaultKvStore: JsonKvStore
+
+ @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 useAuthorNameTogglesOn() {
+ // Turn on "Use author name" preference if currently off
+ if (!defaultKvStore.getBoolean("useAuthorName", false)) {
+ 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
+ .onView(
+ allOf(
+ withId(R.id.recycler_view),
+ childAtPosition(withId(android.R.id.list_container), 0),
+ ),
+ ).check(matches(isEnabled()))
+ }
+
+ @Test
+ fun orientationChange() {
+ UITestHelper.changeOrientation(activityRule)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
new file mode 100644
index 000000000..ebb06e4af
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
@@ -0,0 +1,205 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+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
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.rule.ActivityTestRule
+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 {
+ onView(ViewMatchers.withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ // Skip tutorial
+ onView(ViewMatchers.withId(R.id.finishTutorialButton))
+ .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
+ sleep(3000)
+ onView(ViewMatchers.withId(R.id.login_username))
+ .perform(
+ ViewActions.replaceText(getTestUsername()),
+ ViewActions.closeSoftKeyboard(),
+ )
+ sleep(2000)
+ onView(ViewMatchers.withId(R.id.login_password))
+ .perform(
+ ViewActions.replaceText(getTestUserPassword()),
+ ViewActions.closeSoftKeyboard(),
+ )
+ sleep(2000)
+ onView(ViewMatchers.withId(R.id.login_button))
+ .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) {
+ try {
+ Timber.d("Sleeping for %d", timeInMillis)
+ Thread.sleep(timeInMillis)
+ } catch (e: InterruptedException) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun getTestUsername(): String {
+ val username = BuildConfig.TEST_USERNAME
+ if (StringUtils.isEmpty(username) || username == "null") {
+ throw NotImplementedError("Configure your beta account's 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
+ }
+ }
+
+ 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
+ assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
+ }
+
+ fun first(matcher: Matcher): Matcher? {
+ return object : BaseMatcher() {
+ var isFirst = true
+
+ override fun matches(item: Any): Boolean {
+ if (isFirst && matcher.matches(item)) {
+ isFirst = false
+ return true
+ }
+ return false
+ }
+
+ override fun describeTo(description: Description) {
+ description.appendText("should return first matching item")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
new file mode 100644
index 000000000..d3a814f2d
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
@@ -0,0 +1,19 @@
+package fr.free.nrw.commons
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import fr.free.nrw.commons.upload.UploadActivity
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UploadActivityTest {
+ @get:Rule
+ var activityRule = ActivityTestRule(UploadActivity::class.java)
+
+ @Test
+ fun orientationChange() {
+ UITestHelper.changeOrientation(activityRule)
+ }
+}
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
new file mode 100644
index 000000000..048d540b7
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
@@ -0,0 +1,363 @@
+package fr.free.nrw.commons
+
+import android.Manifest
+import android.app.Activity
+import android.app.Instrumentation.ActivityResult
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Environment
+import android.view.View
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.NoMatchingViewException
+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.contrib.RecyclerViewActions
+import androidx.test.espresso.intent.Intents
+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.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 fr.free.nrw.commons.auth.LoginActivity
+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
+import timber.log.Timber
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.text.SimpleDateFormat
+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,
+ )!!
+
+ @get:Rule
+ var activityRule = ActivityTestRule(LoginActivity::class.java)
+
+ private val randomBitmap: Bitmap
+ get() {
+ val random = Random()
+ val bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888)
+ bitmap.eraseColor(random.nextInt(255))
+ return bitmap
+ }
+
+ @Before
+ fun setup() {
+ try {
+ Intents.init()
+ } catch (_: IllegalStateException) {
+ }
+ UITestHelper.loginUser()
+ UITestHelper.skipWelcome()
+ }
+
+ @After
+ fun teardown() {
+ Intents.release()
+ }
+
+ @Test
+ @Ignore("Fix Failing Test")
+ fun testUploadWithDescription() {
+ if (!ConfigUtils.isBetaFlavour) {
+ throw Error("This test should only be run in Beta!")
+ }
+
+ setupSingleUpload("image.jpg")
+
+ openGallery()
+
+ // Validate that an intent to get an image is sent
+ intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*")))
+
+ // Create filename with the current time (to prevent overwrites)
+ val dateFormat = SimpleDateFormat("yyMMdd-hhmmss")
+ val commonsFileName = "MobileTest " + dateFormat.format(Date())
+
+ // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
+ dismissWarning("Yes")
+
+ onView(allOf(isDisplayed(), withId(R.id.tv_title)))
+ .perform(replaceText(commonsFileName))
+
+ onView(allOf(isDisplayed(), withId(R.id.description_item_edit_text)))
+ .perform(replaceText(commonsFileName))
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ UITestHelper.sleep(5000)
+ dismissWarning("Yes")
+
+ UITestHelper.sleep(3000)
+
+ onView(allOf(isDisplayed(), withId(R.id.et_search)))
+ .perform(replaceText("Uploaded with Mobile/Android Tests"))
+
+ UITestHelper.sleep(3000)
+
+ try {
+ onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
+ .perform(click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ dismissWarning("Yes, Submit")
+
+ UITestHelper.sleep(500)
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
+ .perform(click())
+
+ UITestHelper.sleep(10000)
+
+ val fileUrl =
+ "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+ commonsFileName.replace(' ', '_') + ".jpg"
+ Timber.i("File should be uploaded to $fileUrl")
+ }
+
+ private fun dismissWarning(warningText: String) {
+ try {
+ onView(withText(warningText))
+ .check(matches(isDisplayed()))
+ .perform(click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+ }
+
+ @Test
+ @Ignore("Fix Failing Test")
+ fun testUploadWithoutDescription() {
+ if (!ConfigUtils.isBetaFlavour) {
+ throw Error("This test should only be run in Beta!")
+ }
+
+ setupSingleUpload("image.jpg")
+
+ openGallery()
+
+ // Validate that an intent to get an image is sent
+ intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*")))
+
+ // Create filename with the current time (to prevent overwrites)
+ val dateFormat = SimpleDateFormat("yyMMdd-hhmmss")
+ val commonsFileName = "MobileTest " + dateFormat.format(Date())
+
+ // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
+ dismissWarning("Yes")
+
+ onView(allOf(isDisplayed(), withId(R.id.tv_title)))
+ .perform(replaceText(commonsFileName))
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ UITestHelper.sleep(10000)
+ dismissWarning("Yes")
+
+ UITestHelper.sleep(3000)
+
+ onView(allOf(isDisplayed(), withId(R.id.et_search)))
+ .perform(replaceText("Test"))
+
+ UITestHelper.sleep(3000)
+
+ try {
+ onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
+ .perform(click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ dismissWarning("Yes, Submit")
+
+ UITestHelper.sleep(500)
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
+ .perform(click())
+
+ UITestHelper.sleep(10000)
+
+ 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) {
+ throw Error("This test should only be run in Beta!")
+ }
+
+ setupSingleUpload("image.jpg")
+
+ openGallery()
+
+ // Validate that an intent to get an image is sent
+ intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*")))
+
+ // Create filename with the current time (to prevent overwrites)
+ val dateFormat = SimpleDateFormat("yyMMdd-hhmmss")
+ val commonsFileName = "MobileTest " + dateFormat.format(Date())
+
+ // Try to dismiss the error, if there is one (probably about duplicate files on Commons)
+ dismissWarningDialog()
+
+ 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"),
+ ),
+ )
+
+ onView(withId(R.id.btn_add))
+ .perform(click())
+
+ onView(withId(R.id.rv_descriptions)).perform(
+ RecyclerViewActions
+ .actionOnItemAtPosition(
+ 1,
+ MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"),
+ ),
+ )
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ UITestHelper.sleep(5000)
+ dismissWarning("Yes")
+
+ UITestHelper.sleep(3000)
+
+ onView(allOf(isDisplayed(), withId(R.id.et_search)))
+ .perform(replaceText("Test"))
+
+ UITestHelper.sleep(3000)
+
+ try {
+ onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
+ .perform(click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_next)))
+ .perform(click())
+
+ dismissWarning("Yes, Submit")
+
+ UITestHelper.sleep(500)
+
+ onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
+ .perform(click())
+
+ UITestHelper.sleep(10000)
+
+ val fileUrl =
+ "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+ commonsFileName.replace(' ', '_') + ".jpg"
+ Timber.i("File should be uploaded to $fileUrl")
+ }
+
+ private fun setupSingleUpload(imageName: String) {
+ saveToInternalStorage(imageName)
+ singleImageIntent(imageName)
+ }
+
+ private fun saveToInternalStorage(imageName: String) {
+ val bitmapImage = randomBitmap
+
+ // path to /data/data/yourapp/app_data/imageDir
+ val mypath = File(Environment.getExternalStorageDirectory(), imageName)
+
+ Timber.d("Filepath: %s", mypath.path)
+
+ Timber.d("Absolute Filepath: %s", mypath.absolutePath)
+
+ var fos: FileOutputStream? = null
+ try {
+ fos = FileOutputStream(mypath)
+ // Use the compress method on the BitMap object to write image to the OutputStream
+ bitmapImage.compress(Bitmap.CompressFormat.JPEG, 100, fos)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ } finally {
+ try {
+ fos?.close()
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+ }
+
+ private fun singleImageIntent(imageName: String) {
+ // Uri to return by our mock gallery selector
+ // Requires file 'image.jpg' to be placed at root of file structure
+ val imageUri = Uri.parse("file://mnt/sdcard/$imageName")
+
+ // Build a result to return from the Camera app
+ val intent = Intent()
+ intent.data = imageUri
+ val result = ActivityResult(Activity.RESULT_OK, intent)
+
+ // Stub out the File picker. When an intent is sent to the File picker, this tells
+ // Espresso to respond with the ActivityResult we just created
+ intending(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*"))).respondWith(result)
+ }
+
+ private fun dismissWarningDialog() {
+ try {
+ onView(withText("Yes"))
+ .check(matches(isDisplayed()))
+ .perform(click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+ }
+
+ private fun openGallery() {
+ // Open FAB
+ onView(allOf(withId(R.id.fab_plus), isDisplayed()))
+ .perform(click())
+
+ // Click gallery
+ onView(allOf(withId(R.id.fab_gallery), isDisplayed()))
+ .perform(click())
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
new file mode 100644
index 000000000..5956b3c02
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
@@ -0,0 +1,133 @@
+package fr.free.nrw.commons
+
+import androidx.test.espresso.Espresso.onView
+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.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)
+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) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ onView(withId(R.id.finishTutorialButton))
+ .check(matches(isDisplayed()))
+ }
+ }
+
+ @Test
+ fun ifProdHidesSkipButton() {
+ if (!ConfigUtils.isBetaFlavour) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ onView(withId(R.id.finishTutorialButton))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testBetaSkipButton() {
+ if (ConfigUtils.isBetaFlavour) {
+ 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 testSwipingOnce() {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ 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 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())
+ assertThat(true, equalTo(true))
+ onView(withId(R.id.welcomePager))
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ assertThat(true, equalTo(true))
+ }
+
+ @Test
+ fun swipeBeyondBounds() {
+ val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
+
+ 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() {
+ val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
+
+ 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)
+ }
+}
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
new file mode 100644
index 000000000..52ac18e4d
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
@@ -0,0 +1,66 @@
+package fr.free.nrw.commons.util
+
+import android.view.View
+import android.widget.EditText
+import androidx.appcompat.widget.AppCompatSpinner
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import org.hamcrest.Matcher
+
+class MyViewAction {
+ companion object {
+ fun typeTextInChildViewWithId(
+ id: Int,
+ textToBeTyped: String,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
+
+ override fun getDescription(): String = "Click on a child view with specified id."
+
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
+ val v = view.findViewById(id) as EditText
+ v.setText(textToBeTyped)
+ }
+ }
+
+ fun selectSpinnerItemInChildViewWithId(
+ id: Int,
+ position: Int,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
+
+ override fun getDescription(): String = "Click on a child view with specified id."
+
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
+ val v = view.findViewById(id) as AppCompatSpinner
+ v.setSelection(position)
+ }
+ }
+
+ fun clickItemWithId(
+ id: Int,
+ position: Int,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
+
+ override fun getDescription(): String = "Click on a child view with specified id."
+
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
+ val v = view.findViewById(id) as View
+ v.performClick()
+ }
+ }
+ }
+}
diff --git a/app/src/beta/res/values/adapter.xml b/app/src/beta/res/values/adapter.xml
new file mode 100644
index 000000000..8e2257563
--- /dev/null
+++ b/app/src/beta/res/values/adapter.xml
@@ -0,0 +1,7 @@
+
+
+ fr.free.nrw.commons.beta
+ fr.free.nrw.commons.beta.contributions.contentprovider
+ fr.free.nrw.commons.beta.modifications.contentprovider
+ fr.free.nrw.commons.beta.categories.contentprovider
+
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/betaDebug/ic_launcher-web.png b/app/src/betaDebug/ic_launcher-web.png
new file mode 100644
index 000000000..5b1546360
Binary files /dev/null and b/app/src/betaDebug/ic_launcher-web.png differ
diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..90c044ccd
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..f826d5544
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..9b273c43f
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..b09b8d252
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..5002ec69d
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..9aa2611ba
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d7b349b4d
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..9297963fd
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..59b088069
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d473d0aed
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..aeb616311
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..0b7797049
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e88874931
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..fa5017d72
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..00a9e4bd5
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..17917666d
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_explore-web.png b/app/src/main/ic_explore-web.png
new file mode 100644
index 000000000..55718bec5
Binary files /dev/null and b/app/src/main/ic_explore-web.png differ
diff --git a/app/src/main/ic_filled_star-web.png b/app/src/main/ic_filled_star-web.png
new file mode 100644
index 000000000..653e8bbaa
Binary files /dev/null and b/app/src/main/ic_filled_star-web.png differ
diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png
new file mode 100644
index 000000000..c7f0bc3fe
Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ
diff --git a/app/src/main/ic_settings_black-web.png b/app/src/main/ic_settings_black-web.png
new file mode 100644
index 000000000..0b0e6758a
Binary files /dev/null and b/app/src/main/ic_settings_black-web.png differ
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.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/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/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.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.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/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
new file mode 100644
index 000000000..3eb7ee243
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/Urls.kt
@@ -0,0 +1,19 @@
+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 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/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.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.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/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.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.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.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.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.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/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.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.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.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/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.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/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/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.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.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.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.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.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.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/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
new file mode 100644
index 000000000..9f94e8592
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
@@ -0,0 +1,14 @@
+package fr.free.nrw.commons.campaigns
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * A data class to hold the campaign configs
+ */
+class CampaignConfig {
+ @SerializedName("showOnlyLiveCampaigns")
+ var showOnlyLiveCampaigns = false
+
+ @SerializedName("sortBy")
+ 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
new file mode 100644
index 000000000..1656109e7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
@@ -0,0 +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")
+ 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.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.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.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.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/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.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.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.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.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.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/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/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
new file mode 100644
index 000000000..3888ef889
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
@@ -0,0 +1,90 @@
+package fr.free.nrw.commons.category
+
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Single
+import retrofit2.http.GET
+import retrofit2.http.Query
+import retrofit2.http.QueryMap
+
+/**
+ * Interface for interacting with Commons category related APIs
+ */
+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&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14",
+ )
+ fun searchCategories(
+ @Query("gsrsearch") filter: String?,
+ @Query("gsrlimit") itemLimit: Int,
+ @Query("gsroffset") offset: Int,
+ ): Single
+
+ /**
+ * Searches for categories starting with the specified prefix.
+ *
+ * @param prefix The string to be searched
+ * @param itemLimit How many results are returned
+ * @return
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
+ )
+ fun searchCategoriesForPrefix(
+ @Query("gacprefix") prefix: String?,
+ @Query("gaclimit") itemLimit: Int,
+ @Query("gacoffset") offset: Int,
+ ): Single
+
+ /**
+ * Fetches categories starting and ending with a specified name.
+ *
+ * @param startingCategory Name of the category to start
+ * @param endingCategory Name of the category to end
+ * @param itemLimit How many categories to return
+ * @param offset offset
+ * @return MwQueryResponse
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
+ )
+ fun getCategoriesByName(
+ @Query("gacfrom") startingCategory: String?,
+ @Query("gacto") endingCategory: String?,
+ @Query("gaclimit") itemLimit: Int,
+ @Query("gacoffset") offset: Int,
+ ): Single
+
+ /**
+ * Fetches non-hidden categories by titles.
+ *
+ * @param titles titles to fetch categories for (e.g. File:)
+ * @param itemLimit How many categories to return
+ * @return MwQueryResponse
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden",
+ )
+ fun getCategoriesByTitles(
+ @Query("titles") titles: String?,
+ @Query("gcllimit") itemLimit: Int,
+ ): Single
+
+ @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
+ fun getSubCategoryList(
+ @Query("gcmtitle") categoryName: String,
+ @QueryMap(encoded = true) continuation: Map,
+ ): Single
+
+ @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50")
+ fun getParentCategoryList(
+ @Query("titles") categoryName: String?,
+ @QueryMap(encoded = true) continuation: Map,
+ ): Single
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt
new file mode 100644
index 000000000..d0ee8d53c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt
@@ -0,0 +1,27 @@
+package fr.free.nrw.commons.category
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class CategoryItem(
+ val name: String,
+ val description: String?,
+ val thumbnail: String?,
+ var isSelected: Boolean,
+) : Parcelable {
+ override fun toString(): String = "CategoryItem: '$name'"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as CategoryItem
+
+ if (name != other.name) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int = name.hashCode()
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt
new file mode 100644
index 000000000..0322cd7b6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons.category
+
+import io.reactivex.Single
+
+abstract class ContinuationClient {
+ private val continuationStore: MutableMap?> = mutableMapOf()
+ private val continuationExists: MutableMap = mutableMapOf()
+
+ private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true
+
+ fun continuationRequest(
+ prefix: String,
+ name: String,
+ requestFunction: (Map) -> Single,
+ ): Single> {
+ val key = "$prefix$name"
+ return if (hasMorePagesFor(key)) {
+ responseMapper(requestFunction(continuationStore[key] ?: emptyMap()), key)
+ } else {
+ Single.just(emptyList())
+ }
+ }
+
+ abstract fun responseMapper(
+ networkResult: Single,
+ key: String? = null,
+ ): Single>
+
+ fun handleContinuationResponse(
+ continuation: Map?,
+ key: String?,
+ ) {
+ if (key != null) {
+ continuationExists[key] =
+ continuation?.let { continuation ->
+ continuationStore[key] = continuation
+ true
+ } ?: false
+ }
+ }
+
+ protected fun resetContinuation(
+ prefix: String,
+ category: String,
+ ) {
+ continuationExists.remove("$prefix$category")
+ continuationStore.remove("$prefix$category")
+ }
+
+ /**
+ * Remove the existing the key from continuationExists and continuationStore
+ *
+ * @param prefix
+ * @param userName the username
+ */
+ protected fun resetUserContinuation(
+ prefix: String,
+ userName: String,
+ ) {
+ continuationExists.remove("$prefix$userName")
+ continuationStore.remove("$prefix$userName")
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
new file mode 100644
index 000000000..0198c61a5
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
@@ -0,0 +1,106 @@
+package fr.free.nrw.commons.category
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.TextView
+import com.facebook.drawee.view.SimpleDraweeView
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+
+
+/**
+ * This is created to only display UI implementation. Needs to be changed in real implementation
+ */
+class GridViewAdapter(
+ context: Context,
+ layoutResourceId: Int,
+ private var data: MutableList?
+) : ArrayAdapter(context, layoutResourceId, data ?: mutableListOf()) {
+
+ /**
+ * Adds more items to the list
+ * It's triggered on scrolling down in the list
+ * @param images
+ */
+ fun addItems(images: List) {
+ if (data == null) {
+ data = mutableListOf()
+ }
+ data?.addAll(images)
+ notifyDataSetChanged()
+ }
+
+ /**
+ * Checks the first item in the new list with the old list and returns true if they are the same
+ * It's triggered on a successful response of the fetch images API.
+ * @param images
+ */
+ fun containsAll(images: List?): Boolean {
+ if (images.isNullOrEmpty()) {
+ return false
+ }
+ if (data.isNullOrEmpty()) {
+ data = mutableListOf()
+ return false
+ }
+ val fileName = data?.get(0)?.filename
+ val imageName = images[0].filename
+ return imageName == fileName
+ }
+
+ override fun isEmpty(): Boolean {
+ return data.isNullOrEmpty()
+ }
+
+ /**
+ * Sets up the UI for the category image item
+ * @param position
+ * @param convertView
+ * @param parent
+ * @return
+ */
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: LayoutInflater.from(context).inflate(
+ R.layout.layout_category_images,
+ parent,
+ false
+ )
+
+ val item = data?.get(position)
+ val imageView = view.findViewById(R.id.categoryImageView)
+ val fileName = view.findViewById(R.id.categoryImageTitle)
+ val uploader = view.findViewById(R.id.categoryImageAuthor)
+
+ item?.let {
+ fileName.text = it.mostRelevantCaption
+ setUploaderView(it, uploader)
+ imageView.setImageURI(it.thumbUrl)
+ }
+
+ return view
+ }
+
+ /**
+ * @return the Media item at the given position
+ */
+ override fun getItem(position: Int): Media? {
+ return data?.get(position)
+ }
+
+ /**
+ * Shows author information if it's present
+ * @param item
+ * @param uploader
+ */
+ @SuppressLint("StringFormatInvalid")
+ private fun setUploaderView(item: Media, uploader: TextView) {
+ uploader.text = context.getString(
+ R.string.image_uploaded_by,
+ item.getAuthorOrUser()
+ )
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt
new file mode 100644
index 000000000..68200992c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.category
+
+interface OnCategoriesSaveHandler {
+ fun onCategoriesSave(categories: List)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
new file mode 100644
index 000000000..378a98893
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.concurrency
+
+import fr.free.nrw.commons.BuildConfig
+
+
+class BackgroundPoolExceptionHandler : ExceptionHandler {
+ /**
+ * If an exception occurs on a background thread, this handler will crash for debug builds
+ * but fail silently for release builds.
+ * @param t
+ */
+ override fun onException(t: Throwable) {
+ // Crash for debug build
+ if (BuildConfig.DEBUG) {
+ val thread = Thread {
+ throw RuntimeException(t)
+ }
+ thread.start()
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt
new file mode 100644
index 000000000..7605964bd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.concurrency
+
+import java.util.concurrent.CancellationException
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadFactory
+
+
+class ExceptionAwareThreadPoolExecutor(
+ corePoolSize: Int,
+ threadFactory: ThreadFactory,
+ private val exceptionHandler: ExceptionHandler?
+) : ScheduledThreadPoolExecutor(corePoolSize, threadFactory) {
+
+ override fun afterExecute(r: Runnable, t: Throwable?) {
+ super.afterExecute(r, t)
+ var throwable = t
+
+ if (throwable == null && r is Future<*>) {
+ try {
+ if (r.isDone) {
+ r.get()
+ }
+ } catch (_: CancellationException) {
+ // ignore
+ } catch (_: InterruptedException) {
+ // ignore
+ } catch (e: ExecutionException) {
+ throwable = e.cause ?: e
+ } catch (e: Exception) {
+ throwable = e
+ }
+ }
+
+ throwable?.let {
+ exceptionHandler?.onException(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt
new file mode 100644
index 000000000..6b3d2a0f7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt
@@ -0,0 +1,7 @@
+package fr.free.nrw.commons.concurrency
+
+interface ExceptionHandler {
+
+ fun onException(t: Throwable)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt
new file mode 100644
index 000000000..46138d676
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt
@@ -0,0 +1,122 @@
+package fr.free.nrw.commons.concurrency
+
+import java.util.concurrent.Callable
+import java.util.concurrent.Executor
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+
+
+/**
+ * This class is a thread pool which provides some additional features:
+ * - it sets the thread priority to a value lower than foreground priority by default, or you can
+ * supply your own priority
+ * - it gives you a way to handle exceptions thrown in the thread pool
+ */
+class ThreadPoolService private constructor(builder: Builder) : Executor {
+ private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor(
+ builder.poolSize,
+ object : ThreadFactory {
+ private var count = 0
+ override fun newThread(r: Runnable): Thread {
+ count++
+ val t = Thread(r, "${builder.name}-$count")
+ // If the priority is specified out of range, we set the thread priority to
+ // Thread.MIN_PRIORITY
+ // It's done to prevent IllegalArgumentException and to prevent setting of
+ // improper high priority for a less priority task
+ t.priority =
+ if (
+ builder.priority > Thread.MAX_PRIORITY
+ ||
+ builder.priority < Thread.MIN_PRIORITY
+ ) {
+ Thread.MIN_PRIORITY
+ } else {
+ builder.priority
+ }
+ return t
+ }
+ },
+ builder.exceptionHandler
+ )
+
+ fun schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture {
+ return backgroundPool.schedule(callable, time, timeUnit)
+ }
+
+ fun schedule(runnable: Runnable): ScheduledFuture<*> {
+ return schedule(runnable, 0, TimeUnit.SECONDS)
+ }
+
+ fun schedule(runnable: Runnable, time: Long, timeUnit: TimeUnit): ScheduledFuture<*> {
+ return backgroundPool.schedule(runnable, time, timeUnit)
+ }
+
+ fun scheduleAtFixedRate(
+ task: Runnable,
+ initialDelay: Long,
+ period: Long,
+ timeUnit: TimeUnit
+ ): ScheduledFuture<*> {
+ return backgroundPool.scheduleWithFixedDelay(task, initialDelay, period, timeUnit)
+ }
+
+ fun executor(): ScheduledThreadPoolExecutor {
+ return backgroundPool
+ }
+
+ fun shutdown() {
+ backgroundPool.shutdown()
+ }
+
+ override fun execute(command: Runnable) {
+ backgroundPool.execute(command)
+ }
+
+ /**
+ * Builder class for [ThreadPoolService]
+ */
+ class Builder(val name: String) {
+ var poolSize: Int = 1
+ var priority: Int = Thread.MIN_PRIORITY
+ var exceptionHandler: ExceptionHandler? = null
+
+ /**
+ * @param poolSize the number of threads to keep in the pool
+ * @throws IllegalArgumentException if size of pool <= 0
+ */
+ fun setPoolSize(poolSize: Int): Builder {
+ if (poolSize <= 0) {
+ throw IllegalArgumentException("Pool size must be greater than 0")
+ }
+ this.poolSize = poolSize
+ return this
+ }
+
+ /**
+ * @param priority Priority of the threads in the service. You can supply a constant from
+ * [java.lang.Thread] or
+ * specify your own priority in the range 1(MIN_PRIORITY)
+ * to 10(MAX_PRIORITY)
+ * By default, the priority is set to [java.lang.Thread.MIN_PRIORITY]
+ */
+ fun setPriority(priority: Int): Builder {
+ this.priority = priority
+ return this
+ }
+
+ /**
+ * @param handler The handler to use to handle exceptions in the service
+ */
+ fun setExceptionHandler(handler: ExceptionHandler): Builder {
+ exceptionHandler = handler
+ return this
+ }
+
+ fun build(): ThreadPoolService {
+ return ThreadPoolService(this)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt
new file mode 100644
index 000000000..b611574b0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt
@@ -0,0 +1,35 @@
+package fr.free.nrw.commons.contributions
+
+import android.os.Parcel
+import android.os.Parcelable
+import fr.free.nrw.commons.upload.UploadResult
+
+data class ChunkInfo(
+ val uploadResult: UploadResult?,
+ val indexOfNextChunkToUpload: Int,
+ val totalChunks: Int,
+) : Parcelable {
+ constructor(parcel: Parcel) : this(
+ parcel.readParcelable(UploadResult::class.java.classLoader),
+ parcel.readInt(),
+ parcel.readInt(),
+ ) {
+ }
+
+ override fun writeToParcel(
+ parcel: Parcel,
+ flags: Int,
+ ) {
+ parcel.writeParcelable(uploadResult, flags)
+ parcel.writeInt(indexOfNextChunkToUpload)
+ parcel.writeInt(totalChunks)
+ }
+
+ override fun describeContents(): Int = 0
+
+ companion object CREATOR : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel)
+
+ override fun newArray(size: Int): Array = arrayOfNulls(size)
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt
new file mode 100644
index 000000000..d623730ab
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt
@@ -0,0 +1,125 @@
+package fr.free.nrw.commons.contributions
+
+import android.net.Uri
+import android.os.Parcelable
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.upload.UploadItem
+import fr.free.nrw.commons.upload.UploadMediaDetail
+import fr.free.nrw.commons.upload.WikidataPlace
+import fr.free.nrw.commons.upload.WikidataPlace.Companion.from
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+import kotlinx.parcelize.Parcelize
+import java.io.File
+import java.util.Date
+
+@Entity(tableName = "contribution")
+@Parcelize
+data class Contribution constructor(
+ @Embedded(prefix = "media_") val media: Media,
+ @PrimaryKey val pageId: String = media.pageId,
+ var state: Int = 0,
+ var transferred: Long = 0,
+ val decimalCoords: String? = null,
+ var dateCreatedSource: String? = null,
+ var wikidataPlace: WikidataPlace? = null,
+ var chunkInfo: ChunkInfo? = null,
+ var errorInfo: String? = null,
+ /**
+ * @return array list of entityids for the depictions
+ *
+ * Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id
+ * is in turn used to upload depictions to wikibase
+ */
+ val depictedItems: List = ArrayList(),
+ var mimeType: String? = null,
+ val localUri: Uri? = null,
+ var dataLength: Long = 0,
+ var dateCreated: Date? = null,
+ var dateCreatedString: String? = null,
+ var dateModified: Date? = null,
+ var dateUploadStarted: Date? = null,
+ var hasInvalidLocation: Int = 0,
+ var contentUri: Uri? = null,
+ var countryCode: String? = null,
+ var imageSHA1: String? = null,
+ /**
+ * Number of times a contribution has been retried after a failure
+ */
+ var retries: Int = 0,
+) : Parcelable {
+ fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED)
+
+ constructor(
+ item: UploadItem,
+ sessionManager: SessionManager,
+ depictedItems: List,
+ categories: List,
+ imageSHA1: String,
+ ) : this(
+ Media(
+ formatCaptions(item.uploadMediaDetails),
+ categories,
+ item.filename,
+ formatDescriptions(item.uploadMediaDetails),
+ sessionManager.userName,
+ sessionManager.userName,
+ ),
+ localUri = item.mediaUri,
+ decimalCoords = item.gpsCoords?.decimalCoords,
+ dateCreatedSource = "",
+ depictedItems = depictedItems,
+ wikidataPlace = from(item.place),
+ contentUri = item.contentUri,
+ dateCreatedString = item.fileCreatedDateString,
+ imageSHA1 = imageSHA1,
+ )
+
+ /**
+ * Set this true when ImageProcessor has said that the location is invalid
+ * @param hasInvalidLocation
+ */
+ fun setHasInvalidLocation(hasInvalidLocation: Boolean) {
+ this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0
+ }
+
+ fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1
+
+ companion object {
+ const val STATE_COMPLETED = -1
+ const val STATE_FAILED = 1
+ const val STATE_QUEUED = 2
+ const val STATE_IN_PROGRESS = 3
+ const val STATE_PAUSED = 4
+
+ /**
+ * Formatting captions to the Wikibase format for sending labels
+ * @param uploadMediaDetails list of media Details
+ */
+ fun formatCaptions(uploadMediaDetails: List) =
+ uploadMediaDetails
+ .associate { it.languageCode!! to it.captionText }
+ .filter { it.value.isNotBlank() }
+
+ /**
+ * Formats the list of descriptions into the format Commons requires for uploads.
+ *
+ * @param descriptions the list of descriptions, description is ignored if text is null.
+ * @return a string with the pattern of {{en|1=descriptionText}}
+ */
+ fun formatDescriptions(descriptions: List) =
+ descriptions
+ .filter { !it.descriptionText.isNullOrEmpty() }
+ .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
+ }
+
+ val fileKey: String? get() = chunkInfo?.uploadResult?.filekey
+ val localUriPath: File? get() = localUri?.path?.let { File(it) }
+
+ fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
+
+ fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
new file mode 100644
index 000000000..b5075a21e
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
@@ -0,0 +1,122 @@
+package fr.free.nrw.commons.contributions
+
+import androidx.paging.PagedList.BoundaryCallback
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
+import fr.free.nrw.commons.media.MediaClient
+import io.reactivex.Scheduler
+import io.reactivex.disposables.CompositeDisposable
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Named
+
+/**
+ * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that
+ * is triggered for various boundary conditions in the list
+ */
+class ContributionBoundaryCallback
+ @Inject
+ constructor(
+ private val repository: ContributionsRepository,
+ private val sessionManager: SessionManager,
+ private val mediaClient: MediaClient,
+ @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler,
+ ) : BoundaryCallback() {
+ private val compositeDisposable: CompositeDisposable = CompositeDisposable()
+ var userName: String? = null
+
+ /**
+ * It is triggered when the list has no items User's Contributions are then fetched from the
+ * network
+ */
+ override fun onZeroItemsLoaded() {
+ refreshList()
+ }
+
+ /**
+ * It is triggered when the user scrolls to the top of the list
+ * */
+ override fun onItemAtFrontLoaded(itemAtFront: Contribution) {
+ }
+
+ /**
+ * It is triggered when the user scrolls to the end of the list. User's Contributions are then
+ * fetched from the network
+ */
+ override fun onItemAtEndLoaded(itemAtEnd: Contribution) {
+ fetchContributions()
+ }
+
+ /**
+ * Fetch list from network and save it to local DB.
+ *
+ * @param onRefreshFinish callback to invoke when operations finishes
+ * with either error or success.
+ */
+ fun refreshList(onRefreshFinish: () -> Unit = {}){
+ if (sessionManager.userName != null) {
+ mediaClient.resetUserNameContinuation(sessionManager.userName!!)
+ }
+ fetchContributions(onRefreshFinish)
+ }
+
+ /**
+ * Fetches contributions using the MediaWiki API
+ *
+ * @param onRefreshFinish callback to invoke when operations finishes
+ * with either error or success.
+ */
+ private fun fetchContributions(onRefreshFinish: () -> Unit = {}) {
+ if (sessionManager.userName != null) {
+ userName
+ ?.let { userName ->
+ mediaClient
+ .getMediaListForUser(userName)
+ .map { mediaList ->
+ mediaList.map { media ->
+ Contribution(media = media, state = Contribution.STATE_COMPLETED)
+ }
+ }.subscribeOn(ioThreadScheduler)
+ .subscribe({ list ->
+ saveContributionsToDB(list, onRefreshFinish)
+ },{ error ->
+ onRefreshFinish()
+ Timber.e(
+ "Failed to fetch contributions: %s",
+ error.message,
+ )
+ })
+ }?.let {
+ compositeDisposable.add(
+ it,
+ )
+ }
+ } else {
+ compositeDisposable.clear()
+ }
+ }
+
+ /**
+ * Saves the contributions the the local DB
+ *
+ * @param onRefreshFinish callback to invoke when successfully saved to DB.
+ */
+ private fun saveContributionsToDB(contributions: List, onRefreshFinish: () -> Unit) {
+ compositeDisposable.add(
+ repository
+ .save(contributions)
+ .subscribeOn(ioThreadScheduler)
+ .subscribe { longs: List? ->
+ onRefreshFinish()
+ repository["last_fetch_timestamp"] = System.currentTimeMillis()
+ },
+ )
+ }
+
+ /**
+ * Clean up
+ */
+ fun dispose() {
+ compositeDisposable.dispose()
+ }
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt
new file mode 100644
index 000000000..b9532a12e
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt
@@ -0,0 +1,475 @@
+package fr.free.nrw.commons.contributions
+
+import android.Manifest.permission
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.lifecycle.LiveData
+import androidx.paging.LivePagedListBuilder
+import androidx.paging.PagedList
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.filepicker.DefaultCallback
+import fr.free.nrw.commons.filepicker.FilePicker
+import fr.free.nrw.commons.filepicker.FilePicker.HandleActivityResult
+import fr.free.nrw.commons.filepicker.FilePicker.configuration
+import fr.free.nrw.commons.filepicker.FilePicker.handleExternalImagesPicked
+import fr.free.nrw.commons.filepicker.FilePicker.onPictureReturnedFromDocuments
+import fr.free.nrw.commons.filepicker.FilePicker.openCameraForImage
+import fr.free.nrw.commons.filepicker.FilePicker.openCustomSelector
+import fr.free.nrw.commons.filepicker.FilePicker.openGallery
+import fr.free.nrw.commons.filepicker.UploadableFile
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.location.LocationPermissionsHelper
+import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback
+import fr.free.nrw.commons.location.LocationServiceManager
+import fr.free.nrw.commons.nearby.Place
+import fr.free.nrw.commons.upload.UploadActivity
+import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
+import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE
+import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction
+import fr.free.nrw.commons.utils.ViewUtil.showLongToast
+import fr.free.nrw.commons.utils.ViewUtil.showShortToast
+import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
+import java.util.Arrays
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Singleton
+class ContributionController @Inject constructor(@param:Named("default_preferences") private val defaultKvStore: JsonKvStore) {
+ private var locationBeforeImageCapture: LatLng? = null
+ private var isInAppCameraUpload = false
+ @JvmField
+ var locationPermissionCallback: LocationPermissionCallback? = null
+ private var locationPermissionsHelper: LocationPermissionsHelper? = null
+
+ // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
+ // LiveData