+
+
+
+
\ 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/.travis.yml b/.travis.yml
deleted file mode 100644
index 8c0af5731..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-language: android
-android:
- components:
- - platform-tools
- - tools
- - build-tools-25.0.1
- - extra-google-m2repository
- - extra-android-m2repository
- - android-25
- - sys-img-x86-android-18
-jdk:
- # - openjdk8 # not yet available
- - oraclejdk8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b14e1736..575aa6a32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,13 +1,583 @@
# Wikimedia Commons for Android
-##v2.2 beta
+## 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
+## v2.1
- Added beta opt in link to Settings
- Added Codacy and Butterknife support
- Added Light theme for day/outdoor use
@@ -17,14 +587,14 @@
- Fixed lint issues
- Fixed various crashes
-##v2.0.2
+## v2.0.2
- Make "View in browser" direct to mobile website
-##v2.0.1
+## v2.0.1
- Disabled minify again (reenabling test failed)
- Hotfix for ShareAction bug
-##v2.0
+## 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
@@ -33,98 +603,98 @@
- Reenabled minify in Gradle
- Other minor code optimizations
-##v1.44
+## v1.44
- Attempted fix for GPS suggestions issue
-##v1.43
+## v1.43
- Added translations for multiple languages
- Minor code optimization
-##v1.42
+## v1.42
- Fixed language mappings; successful translatewiki integration
- Various translations added
-##v1.41
+## v1.41
- Bumped min SDK and removed escaped characters for translatewiki.net integration
- Added check for whether file already exists on Commons
-##v1.40
+## v1.40
- Added new pages to tutorial
-##v1.39
+## v1.39
- Fix for Korean translations crash
- Various minor fixes
-##v1.38
+## 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
+## v1.37
- Added category suggestions based on entered title
-##v1.36
+## v1.36
- Fixed Ukranian translations
-##v1.35
+## v1.35
- Fixed issues with GPS category suggestions
-##v1.34
+## v1.34
- Added button to use previous title/desc
-##v1.33
+## 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
+## v1.32
- Use Quadtree source instead of JAR, for F-Droid compatibility
- Fixed GPS extractor not being called
-##v1.31
+## v1.31
- Fixed bug with geolocation category suggestions not being displayed
- Fixed bug with (0,0) being recorded as image location occasionally
-##v1.30
+## v1.30
- Fixed {{Location|null}} template bug
-##v1.29
+## v1.29
- Added new icons to Nearby
- Added link to website on About
-##v1.28
+## 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
+## v1.27
- New feature: List of nearby places without photos
-##v1.26
+## v1.26
- Fixed bug with overwriting files when multiple images selected
-##v1.25
+## v1.25
- Added in-app signup feature for new users
- Fixed crash when reading GPS coordinates
-##v1.24
+## v1.24
- Moved from bits/event.gif to wikimedia/beacon
- Fixed issue with needing to tap gallery again after giving permissions
-##v1.23
+## v1.23
- Added warning if image is submitted without categories
- Added check if back button is pressed at category selection screen
-##v1.22
+## v1.22
- Fixed various crashes
- Crash reports now go to private mailing list to protect user info
-##v1.21
+## v1.21
- Fixed Google Photos multiple share crash
-##v1.20
+## v1.20
- Hotfix for data=null crash
-##v1.19
+## v1.19
- Fixed adapter crash
- Attempt at fixing Google Photos crash
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 812b5329f..3fe6b00d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -19,3 +19,990 @@ their contribution to the product.
* 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 687b53ebb..37f1a7872 100644
--- a/README.md
+++ b/README.md
@@ -1,92 +1,58 @@
-# Wikimedia Commons Android app [](https://travis-ci.org/commons-app/apps-android-commons)
+# 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)
-The Wikimedia Commons Android app allows users to upload pictures from their Android phone/tablet to Wikimedia Commons. Download the app [here][8], or view our [website][9].
+The Wikimedia Commons Android app allows users to upload pictures from their Android phone/tablet to Wikimedia Commons. Download the app [here][1], or view our [website][2].
-Initially started by the Wikimedia Foundation, this app is now maintained by volunteers. Anyone is welcome to improve it, just choose among the [open issues](https://github.com/commons-app/apps-android-commons/issues) and send us a pull request :-)
+Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request! :-)
-
+
-## Develop with Android Studio or IntelliJ ##
+## Documentation
-[Download Android Studio][1] (recommended) or [IntelliJ][2].
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
-1. Open Android Studio/IntelliJ. Open the project:
- ``File`` > ``New`` > ``Project from Version Control...`` > ``Git``
- or
- (From Quick Start menu): ``Check out project from Version Control``
-2. Enter ``https://github.com/commons-app/apps-android-commons/`` as Git Repository URL. Specify a (new) local directory you would like to clone into and select ``OK``.
+* [User Documentation][5]
+* [Contributor Documentation][6]
+ * [Volunteers Welcome!][7]
+* [Developer Documentation][8]
+ * [Libraries Used][9]
-## Build Manually ##
+## Contributors ##
-### Requirements ###
+Thank you all for your work!
-1. Java SDK 8 (OpenJDK 8 or Oracle Java SE 8)
-2. [Android SDK][3] (Level 23)
-3. [Gradle][4]
+| [ 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) |
-### Build Instructions ###
-1. Set the environment variable `ANDROID_HOME` to be the path to your Android SDK
-2. Set the environment variable `JAVA_HOME` to the path to your Java SDK
-3. Run `gradlew.bat assembleDebug` (Windows) or `./gradlew assembleDebug` (Mac / Linux) to build an unisgned apk
-4. Alternatively, you can also connect your Android device via USB and install the app on it directly by running `gradlew.bat installDebug` (Windows) or `./gradlew installDebug` (Mac / Linux)
-There are more thorough instructions on the [Android Developers website][5]
+.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).
## License ##
-This software is open source, licensed under the [Apache License 2.0][6].
-
-## 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/studio/index.html
-[2]: http://www.jetbrains.com/idea/download/index.html
-[3]: https://developer.android.com/sdk/index.html
-[4]: http://gradle.org/gradle-download/
-[5]: https://developer.android.com/studio/build/building-cmdline.html
-[6]: https://www.apache.org/licenses/LICENSE-2.0
-[7]: https://github.com/commons-app/apps-android-commons/issues
-[8]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
-[9]: https://commons-app.github.io/
+[10]: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index fb42cd2a9..000000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,49 +0,0 @@
-apply plugin: 'com.android.application'
-apply from: 'quality.gradle'
-
-dependencies {
- compile 'com.github.nicolas-raoul:Quadtree:ac16ea8035bf07'
- compile 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
- compile 'in.yuvi:http.fluent:1.3'
- compile 'com.android.volley:volley:1.0.0'
- compile 'com.nostra13.universalimageloader:universal-image-loader:1.8.4'
- compile 'ch.acra:acra:4.7.0'
- compile 'org.mediawiki:api:1.3'
- compile 'commons-codec:commons-codec:1.10'
- compile "com.android.support:support-v4:${project.supportLibVersion}"
- compile "com.android.support:appcompat-v7:${project.supportLibVersion}"
- compile "com.android.support:design:${project.supportLibVersion}"
- compile 'com.google.code.gson:gson:2.7'
- compile "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
- annotationProcessor "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
-
- testCompile 'junit:junit:4.12'
-}
-
-android {
- compileSdkVersion project.compileSdkVersion
- buildToolsVersion project.buildToolsVersion
-
- useLibrary 'org.apache.http.legacy'
-
- defaultConfig {
- applicationId 'fr.free.nrw.commons'
- versionCode 67
- versionName '2.2'
- minSdkVersion project.minSdkVersion
- targetSdkVersion project.targetSdkVersion
- }
-
- buildTypes {
- release {
- minifyEnabled false // See https://stackoverflow.com/questions/40232404/google-play-apk-and-android-studio-apk-usb-debug-behaving-differently - proguard.cfg modification alone insufficient.
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
- }
- }
-
- lintOptions {
- disable 'MissingTranslation'
- disable 'ExtraTranslation'
- abortOnError false
- }
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 000000000..41788128c
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,447 @@
+import java.util.Properties
+import java.io.ByteArrayOutputStream
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.kotlin.parcelize)
+}
+
+apply(from = "$rootDir/jacoco.gradle")
+
+val isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file("../play.p12").exists()
+
+if (isRunningOnTravisAndIsNotPRBuild) {
+ apply(plugin = "com.github.triplet.play")
+}
+
+android {
+ namespace = "fr.free.nrw.commons"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "fr.free.nrw.commons"
+ minSdk = 21
+ targetSdk = 35
+ versionCode = 1059
+ versionName = "6.1.0"
+
+ setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
+
+ multiDexEnabled = true
+
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ sourceSets {
+ getByName("test") {
+ // Use kotlin only in tests (for now)
+ java.srcDirs("src/test/kotlin")
+
+ // Use main assets and resources in test
+ assets.srcDirs("src/main/assets")
+ resources.srcDirs("src/main/resources")
+ }
+ }
+
+ signingConfigs {
+ create("release") {
+ // Configure keystore based on env vars in Travis for automated alpha builds
+ if(isRunningOnTravisAndIsNotPRBuild) {
+ storeFile = file("../nr-commons.keystore")
+ storePassword = System.getenv("keystore_password")
+ keyAlias = System.getenv("key_alias")
+ keyPassword = System.getenv("key_password")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
+ testProguardFile("test-proguard-rules.txt")
+
+ signingConfig = signingConfigs.getByName("debug")
+ if (isRunningOnTravisAndIsNotPRBuild) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ debug {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
+ testProguardFile("test-proguard-rules.txt")
+
+ versionNameSuffix = "-debug-" + getBranchName()
+ enableUnitTestCoverage = true
+ enableAndroidTestCoverage = true
+ }
+ }
+
+ configurations.all {
+ resolutionStrategy {
+ force("androidx.annotation:annotation:1.1.0")
+ force("com.jakewharton.timber:timber:4.7.1")
+ force("androidx.fragment:fragment:1.3.6")
+ }
+ exclude(module = "okhttp-ws")
+ }
+
+ flavorDimensions += "tier"
+ productFlavors {
+ create("prod") {
+ dimension = "tier"
+ applicationId = "fr.free.nrw.commons"
+
+ buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
+ buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"")
+ buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
+ buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+ buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
+ buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"")
+ buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"")
+ buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"")
+ buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.org\"")
+ buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+ buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"")
+ buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"")
+ buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
+ buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"")
+ buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"")
+ buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
+ buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
+ buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"")
+ buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"")
+ buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"")
+ buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\"")
+ buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\"")
+ buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.recentlanguages.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.items.contentprovider\"")
+ buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
+ buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
+ buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
+ buildConfigField("String", "DEPICTS_PROPERTY", "\"P180\"")
+ buildConfigField("String", "CREATOR_PROPERTY", "\"P170\"")
+ }
+
+ create("beta") {
+ dimension = "tier"
+ applicationId = "fr.free.nrw.commons.beta"
+
+ // What values do we need to hit the BETA versions of the site / api ?
+ buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
+ buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"")
+ buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
+ buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+ buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
+ buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"")
+ buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"")
+ buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"")
+ buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"")
+ buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
+ buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"")
+ buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"")
+ buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
+ buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"")
+ buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"")
+ buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
+ buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
+ buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"")
+ buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"")
+ buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"")
+ buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\"")
+ buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\"")
+ buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.beta.recentlanguages.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\"")
+ buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.items.contentprovider\"")
+ buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
+ buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
+ buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
+ buildConfigField("String", "DEPICTS_PROPERTY", "\"P245962\"")
+ buildConfigField("String", "CREATOR_PROPERTY", "\"P253075\"")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ buildConfig = true
+ viewBinding = true
+ compose = true
+ }
+ buildToolsVersion = buildToolsVersion
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+ packaging {
+ jniLibs {
+ excludes += listOf("META-INF/androidx.*")
+ }
+ resources {
+ excludes += listOf(
+ "META-INF/androidx.*",
+ "META-INF/proguard/androidx-annotations.pro",
+ "/META-INF/LICENSE.md",
+ "/META-INF/LICENSE-notice.md"
+ )
+ }
+ }
+ testOptions {
+ animationsDisabled = true
+ unitTests {
+ isReturnDefaultValues = true
+ isIncludeAndroidResources = true
+ }
+ unitTests.all {
+ it.jvmArgs("-noverify")
+ }
+ }
+ lint {
+ abortOnError = false
+ disable += listOf("MissingTranslation", "ExtraTranslation")
+ }
+}
+
+dependencies {
+ // Utils
+ implementation(libs.gson)
+ implementation(libs.okhttp)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.converter.gson)
+ implementation(libs.retrofit.adapter.rxjava)
+ implementation(libs.rxandroid)
+ implementation(libs.rxjava)
+ implementation(libs.rxbinding)
+ implementation(libs.rxbinding.appcompat)
+ implementation(libs.facebook.fresco)
+ implementation(libs.facebook.fresco.middleware)
+ implementation(libs.apache.commons.lang3)
+
+ // UI
+ implementation("${libs.viewpagerindicator.library.get()}@aar")
+ implementation(libs.photoview)
+ implementation(libs.android.sdk)
+ implementation(libs.android.plugin.scalebar)
+
+ implementation(libs.timber)
+ implementation(libs.android.material)
+ implementation(libs.dexter)
+
+ // Jetpack Compose
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.ui.viewbinding)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.foundation)
+ implementation(libs.androidx.foundation.layout)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+
+ implementation(libs.adapterdelegates4.kotlin.dsl.viewbinding)
+ implementation(libs.adapterdelegates4.pagination)
+ implementation(libs.androidx.paging.runtime.ktx)
+ testImplementation(libs.androidx.paging.common.ktx)
+ implementation(libs.androidx.paging.rxjava2.ktx)
+ implementation(libs.androidx.recyclerview)
+
+ // Logging
+ implementation(libs.acra.dialog)
+ implementation(libs.acra.mail)
+ implementation(libs.slf4j.api)
+ implementation(libs.logback.android.classic) {
+ exclude(group = "com.google.android", module = "android")
+ }
+ implementation(libs.logging.interceptor)
+
+ // Dependency injector
+ implementation(libs.dagger.android)
+ implementation(libs.dagger.android.support)
+ kapt(libs.dagger.android.processor)
+ kapt(libs.dagger.compiler)
+ annotationProcessor(libs.dagger.android.processor)
+
+ implementation(libs.kotlin.reflect)
+
+ //Mocking
+ testImplementation(libs.mockito.kotlin)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.powermock.module.junit)
+ testImplementation(libs.powermock.api.mockito)
+ testImplementation(libs.mockk)
+
+ // Unit testing
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.runner)
+ testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.androidx.test.rules)
+ testImplementation(libs.mockwebserver)
+ testImplementation(libs.livedata.testing.ktx)
+ testImplementation(libs.androidx.core.testing)
+ testImplementation(libs.junit.jupiter.api)
+ testRuntimeOnly(libs.junit.jupiter.engine)
+ testImplementation(libs.soloader)
+ testImplementation(libs.kotlinx.coroutines.test)
+ debugImplementation(libs.androidx.fragment.testing)
+ testImplementation(libs.commons.io)
+
+ // Android testing
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.androidx.espresso.intents)
+ androidTestImplementation(libs.androidx.espresso.contrib)
+ androidTestImplementation(libs.androidx.runner)
+ androidTestImplementation(libs.androidx.test.rules)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.annotation)
+ androidTestImplementation(libs.mockwebserver)
+ androidTestImplementation(libs.androidx.uiautomator)
+
+ // Debugging
+ debugImplementation(libs.leakcanary.android)
+
+ // Support libraries
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.cardview)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.exifinterface)
+ implementation(libs.recyclerview.fastscroll)
+
+ //swipe_layout
+ implementation(libs.swipelayout.library)
+
+ //Room
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ implementation(libs.androidx.room.rxjava)
+ kapt(libs.androidx.room.compiler)
+
+ // Preferences
+ implementation(libs.androidx.preference)
+ implementation(libs.androidx.preference.ktx)
+
+ //Android Media
+ implementation(libs.juanitobananas.androidDmediaUtil)
+ implementation(libs.androidx.multidex)
+
+ // Kotlin + coroutines
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.kotlinx.coroutines.rx2)
+ testImplementation(libs.androidx.work.testing)
+
+ //Glide
+ implementation(libs.glide)
+ annotationProcessor(libs.glide.compiler)
+ kaptTest(libs.androidx.databinding.compiler)
+ kaptAndroidTest(libs.androidx.databinding.compiler)
+
+ implementation(libs.coordinates2country.android) {
+ exclude(group = "com.google.android", module = "android")
+ }
+
+ //OSMDroid
+ implementation(libs.osmdroid.android)
+ constraints {
+ implementation(libs.kotlin.stdlib.jdk7) {
+ because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
+ }
+ implementation(libs.kotlin.stdlib.jdk8) {
+ because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
+ }
+ }
+}
+
+tasks.register("disableAnimations") {
+ val adb = "${System.getenv("ANDROID_HOME")}/platform-tools/adb"
+ commandLine(adb, "shell", "settings", "put", "global", "window_animation_scale", "0")
+ commandLine(adb, "shell", "settings", "put", "global", "transition_animation_scale", "0")
+ commandLine(adb, "shell", "settings", "put", "global", "animator_duration_scale", "0")
+}
+
+project.gradle.taskGraph.whenReady {
+ val connectedBetaDebugAndroidTest = tasks.named("connectedBetaDebugAndroidTest")
+ val connectedProdDebugAndroidTest = tasks.named("connectedProdDebugAndroidTest")
+
+ connectedBetaDebugAndroidTest.configure {
+ dependsOn("disableAnimations")
+ }
+ connectedProdDebugAndroidTest.configure {
+ dependsOn("disableAnimations")
+ }
+}
+
+fun getTestUserName(): String? {
+ val propFile = rootProject.file("./local.properties")
+ val properties = Properties()
+ propFile.inputStream().use { properties.load(it) }
+ return properties.getProperty("TEST_USER_NAME")
+}
+
+fun getTestPassword(): String? {
+ val propFile = rootProject.file("./local.properties")
+ val properties = Properties()
+ propFile.inputStream().use { properties.load(it) }
+ return properties.getProperty("TEST_USER_PASSWORD")
+}
+
+if (isRunningOnTravisAndIsNotPRBuild) {
+ configure {
+ track = "alpha"
+ userFraction = 1.0
+ serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
+ serviceAccountCredentials = file("../play.p12")
+
+ resolutionStrategy = "auto"
+ outputProcessor { // this: ApkVariantOutput
+ versionNameOverride = "$versionNameOverride.$versionCode"
+ }
+ }
+}
+
+fun getBuildVersion(): String? {
+ return try {
+ val stdout = ByteArrayOutputStream()
+ exec {
+ commandLine("git", "rev-parse", "--short", "HEAD")
+ standardOutput = stdout
+ }
+ stdout.toString().trim()
+ } catch (e: Exception) {
+ null
+ }
+}
+
+fun getBranchName(): String? {
+ return try {
+ val stdout = ByteArrayOutputStream()
+ exec {
+ commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
+ standardOutput = stdout
+ }
+ stdout.toString().trim()
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt
index bbf3a3f0d..21c584ba9 100644
--- a/app/proguard-rules.txt
+++ b/app/proguard-rules.txt
@@ -1,5 +1,100 @@
-dontobfuscate
+-ignorewarnings
+
+-dontnote **
+-dontwarn net.bytebuddy.**
+-dontwarn org.mockito.**
+
+# --- Apache ---
-keep class org.apache.http.** { *; }
--dontwarn org.apache.http.**
--keep class fr.free.nrw.commons.upload.MwVolleyApi$Page {*;}
--keep class android.support.v7.widget.ShareActionProvider { *; }
\ No newline at end of file
+-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/quality.gradle b/app/quality.gradle
deleted file mode 100644
index a63a02720..000000000
--- a/app/quality.gradle
+++ /dev/null
@@ -1,45 +0,0 @@
-apply plugin: 'checkstyle'
-apply plugin: 'pmd'
-
-check.dependsOn 'checkstyle', 'pmd'
-
-checkstyle {
- toolVersion = '7.5.1'
-}
-
-task checkstyle(type: Checkstyle) {
- configFile file("${project.rootDir}/script/style/checkstyle.xml")
- source 'src'
- include '**/*.java'
- exclude '**/gen/**'
-
- classpath = files()
-
- reports {
- html {
- enabled true
- destination "${project.buildDir}/reports/checkstyle/checkstyle.html"
- }
- }
-}
-
-task pmd(type: Pmd) {
- ignoreFailures = true
- ruleSetFiles = files("${project.rootDir}/ruleset.xml")
- ruleSets = []
-
- source 'src'
- include '**/*.java'
- exclude '**/gen/**'
-
- reports {
- xml.enabled = false
- html.enabled = true
- xml {
- destination "${project.buildDir}/reports/pmd/pmd.xml"
- }
- html {
- destination "${project.buildDir}/reports/pmd/pmd.html"
- }
- }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
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/debug/res/values/placeholder_strings.xml b/app/src/debug/res/values/placeholder_strings.xml
deleted file mode 100644
index dd7a60b20..000000000
--- a/app/src/debug/res/values/placeholder_strings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Overlay
- Name
- Description
-
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ff3dbf5c1..17917666d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,154 +1,259 @@
+
+ xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/fontconfig/fonts.conf b/app/src/main/assets/fontconfig/fonts.conf
deleted file mode 100644
index 445d8ce5d..000000000
--- a/app/src/main/assets/fontconfig/fonts.conf
+++ /dev/null
@@ -1,126 +0,0 @@
-
-
-
-
-
-
-
- fontconfig/fonts
-
-
-
- fontconfig
-
-
-
-
- mono
-
-
- monospace
-
-
-
-
-
-
- sans serif
-
-
- sans-serif
-
-
-
-
-
-
- sans
-
-
- sans-serif
-
-
-
-
-
-
- 0x0020
- 0x00A0
- 0x00AD
- 0x034F
- 0x0600
- 0x0601
- 0x0602
- 0x0603
- 0x06DD
- 0x070F
- 0x115F
- 0x1160
- 0x1680
- 0x17B4
- 0x17B5
- 0x180E
- 0x2000
- 0x2001
- 0x2002
- 0x2003
- 0x2004
- 0x2005
- 0x2006
- 0x2007
- 0x2008
- 0x2009
- 0x200A
- 0x200B
- 0x200C
- 0x200D
- 0x200E
- 0x200F
- 0x2028
- 0x2029
- 0x202A
- 0x202B
- 0x202C
- 0x202D
- 0x202E
- 0x202F
- 0x205F
- 0x2060
- 0x2061
- 0x2062
- 0x2063
- 0x206A
- 0x206B
- 0x206C
- 0x206D
- 0x206E
- 0x206F
- 0x2800
- 0x3000
- 0x3164
- 0xFEFF
- 0xFFA0
- 0xFFF9
- 0xFFFA
- 0xFFFB
-
-
-
- 30
-
-
-
-
-
diff --git a/app/src/main/assets/fontconfig/fonts/truetype/Ubuntu-R.ttf b/app/src/main/assets/fontconfig/fonts/truetype/Ubuntu-R.ttf
deleted file mode 100644
index 45a038bad..000000000
Binary files a/app/src/main/assets/fontconfig/fonts/truetype/Ubuntu-R.ttf and /dev/null differ
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.java b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
deleted file mode 100644
index c5156069b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.Html;
-import android.text.method.LinkMovementMethod;
-import android.widget.TextView;
-
-import fr.free.nrw.commons.theme.BaseActivity;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-
-public class AboutActivity extends BaseActivity {
- @BindView(R.id.about_version) TextView versionText;
- @BindView(R.id.about_license) TextView licenseText;
- @BindView(R.id.about_improve) TextView improveText;
- @BindView(R.id.about_privacy_policy) TextView privacyPolicyText;
- @BindView(R.id.about_uploads_to) TextView uploadsToText;
- @BindView(R.id.about_credits) TextView creditsText;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_about);
-
- ButterKnife.bind(this);
-
- uploadsToText.setText(CommonsApplication.EVENTLOG_WIKI);
- versionText.setText(BuildConfig.VERSION_NAME);
-
- // We can't use formatted strings directly because it breaks with
- // our localization tools. Grab an HTML string and turn it into
- // a formatted string.
- fixFormatting(licenseText, R.string.about_license);
- fixFormatting(improveText, R.string.about_improve);
- fixFormatting(privacyPolicyText, R.string.about_privacy_policy);
- fixFormatting(creditsText, R.string.about_credits);
-
- licenseText.setMovementMethod(LinkMovementMethod.getInstance());
- improveText.setMovementMethod(LinkMovementMethod.getInstance());
- privacyPolicyText.setMovementMethod(LinkMovementMethod.getInstance());
- creditsText.setMovementMethod(LinkMovementMethod.getInstance());
- }
-
- private void fixFormatting(TextView textView, int resource) {
- textView.setText(Html.fromHtml(getResources().getString(resource)));
- }
-}
\ No newline at end of file
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.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
deleted file mode 100644
index ebfc29b90..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
+++ /dev/null
@@ -1,209 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.accounts.AuthenticatorException;
-import android.accounts.OperationCanceledException;
-import android.app.Application;
-import android.content.pm.PackageManager;
-import android.graphics.Bitmap;
-import android.os.Build;
-import android.support.v4.util.LruCache;
-import android.util.Log;
-
-import com.android.volley.RequestQueue;
-import com.android.volley.toolbox.BasicNetwork;
-import com.android.volley.toolbox.DiskBasedCache;
-import com.android.volley.toolbox.HurlStack;
-import com.nostra13.universalimageloader.cache.disc.impl.TotalSizeLimitedDiscCache;
-import com.nostra13.universalimageloader.core.ImageLoader;
-import com.nostra13.universalimageloader.core.ImageLoaderConfiguration;
-import com.nostra13.universalimageloader.utils.StorageUtils;
-
-import org.acra.ACRA;
-import org.acra.ReportingInteractionMode;
-import org.acra.annotation.ReportsCrashes;
-import org.apache.http.conn.ClientConnectionManager;
-import org.apache.http.conn.scheme.PlainSocketFactory;
-import org.apache.http.conn.scheme.Scheme;
-import org.apache.http.conn.scheme.SchemeRegistry;
-import org.apache.http.conn.ssl.SSLSocketFactory;
-import org.apache.http.impl.client.AbstractHttpClient;
-import org.apache.http.impl.client.DefaultHttpClient;
-import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
-import org.apache.http.params.BasicHttpParams;
-import org.apache.http.params.CoreProtocolPNames;
-import org.mediawiki.api.MWApi;
-
-import java.io.IOException;
-
-import fr.free.nrw.commons.auth.WikiAccountAuthenticator;
-import fr.free.nrw.commons.caching.CacheController;
-
-// TODO: Use ProGuard to rip out reporting when publishing
-@ReportsCrashes(
- mailTo = "commons-app-android-private@googlegroups.com",
- mode = ReportingInteractionMode.DIALOG,
- resDialogText = R.string.crash_dialog_text,
- resDialogTitle = R.string.crash_dialog_title,
- resDialogCommentPrompt = R.string.crash_dialog_comment_prompt,
- resDialogOkToast = R.string.crash_dialog_ok_toast
-)
-public class CommonsApplication extends Application {
-
- private MWApi api;
- private Account currentAccount = null; // Unlike a savings account...
- public static final String API_URL = "https://commons.wikimedia.org/w/api.php";
- public static final String IMAGE_URL_BASE = "https://upload.wikimedia.org/wikipedia/commons";
- public static final String HOME_URL = "https://commons.wikimedia.org/wiki/";
- public static final String MOBILE_HOME_URL = "https://commons.m.wikimedia.org/wiki/";
- public static final String EVENTLOG_URL = "https://www.wikimedia.org/beacon/event";
- public static final String EVENTLOG_WIKI = "commonswiki";
-
- public static final Object[] EVENT_UPLOAD_ATTEMPT = {"MobileAppUploadAttempts", 5334329L};
- public static final Object[] EVENT_LOGIN_ATTEMPT = {"MobileAppLoginAttempts", 5257721L};
- public static final Object[] EVENT_SHARE_ATTEMPT = {"MobileAppShareAttempts", 5346170L};
- public static final Object[] EVENT_CATEGORIZATION_ATTEMPT = {"MobileAppCategorizationAttempts", 5359208L};
-
- public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using Android Commons app";
-
- public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
- public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App (%s) Feedback";
-
- public RequestQueue volleyQueue;
-
- public CacheController cacheData;
-
- public static AbstractHttpClient createHttpClient() {
- BasicHttpParams params = new BasicHttpParams();
- SchemeRegistry schemeRegistry = new SchemeRegistry();
- schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
- final SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
- schemeRegistry.register(new Scheme("https", sslSocketFactory, 443));
- ClientConnectionManager cm = new ThreadSafeClientConnManager(params, schemeRegistry);
- params.setParameter(CoreProtocolPNames.USER_AGENT, "Commons/" + BuildConfig.VERSION_NAME + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE);
- return new DefaultHttpClient(cm, params);
- }
-
- public static MWApi createMWApi() {
- return new MWApi(API_URL, createHttpClient());
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- if (!BuildConfig.DEBUG) {
- ACRA.init(this);
- }
- // Fire progress callbacks for every 3% of uploaded content
- System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
- api = createMWApi();
-
- ImageLoaderConfiguration imageLoaderConfiguration = new ImageLoaderConfiguration.Builder(getApplicationContext())
- .discCache(new TotalSizeLimitedDiscCache(StorageUtils.getCacheDirectory(this), 128 * 1024 * 1024))
- .build();
- ImageLoader.getInstance().init(imageLoaderConfiguration);
-
- // Initialize EventLogging
- EventLog.setApp(this);
-
- // based off https://developer.android.com/training/displaying-bitmaps/cache-bitmap.html
- // Cache for 1/8th of available VM memory
- long maxMem = Runtime.getRuntime().maxMemory();
- if (maxMem < 48L * 1024L * 1024L) {
- // Cache only one bitmap if VM memory is too small (such as Nexus One);
- Log.d("Commons", "Skipping bitmap cache; max mem is: " + maxMem);
- imageCache = new LruCache<>(1);
- } else {
- int cacheSize = (int) (maxMem / (1024 * 8));
- Log.d("Commons", "Bitmap cache size " + cacheSize + " from max mem " + maxMem);
- imageCache = new LruCache(cacheSize) {
- @Override
- protected int sizeOf(String key, Bitmap bitmap) {
- int bitmapSize;
- bitmapSize = bitmap.getByteCount();
-
- // The cache size will be measured in kilobytes rather than number of items.
- return bitmapSize / 1024;
- }
- };
- }
-
- //For caching area -> categories
- cacheData = new CacheController();
-
- DiskBasedCache cache = new DiskBasedCache(getCacheDir(), 16 * 1024 * 1024);
- volleyQueue = new RequestQueue(cache, new BasicNetwork(new HurlStack()));
- volleyQueue.start();
- }
-
- private com.android.volley.toolbox.ImageLoader imageLoader;
- private LruCache imageCache;
-
- public com.android.volley.toolbox.ImageLoader getImageLoader() {
- if(imageLoader == null) {
- imageLoader = new com.android.volley.toolbox.ImageLoader(volleyQueue, new com.android.volley.toolbox.ImageLoader.ImageCache() {
- @Override
- public Bitmap getBitmap(String key) {
- return imageCache.get(key);
- }
-
- @Override
- public void putBitmap(String key, Bitmap bitmap) {
- imageCache.put(key, bitmap);
- }
- });
- imageLoader.setBatchedResponseDelay(0);
- }
- return imageLoader;
- }
-
- public MWApi getApi() {
- return api;
- }
-
- public Account getCurrentAccount() {
- if(currentAccount == null) {
- AccountManager accountManager = AccountManager.get(this);
- Account[] allAccounts = accountManager.getAccountsByType(WikiAccountAuthenticator.COMMONS_ACCOUNT_TYPE);
- if(allAccounts.length != 0) {
- currentAccount = allAccounts[0];
- }
- }
- return currentAccount;
- }
-
- public Boolean revalidateAuthToken() {
- AccountManager accountManager = AccountManager.get(this);
- Account curAccount = getCurrentAccount();
-
- if(curAccount == null) {
- return false; // This should never happen
- }
-
- accountManager.invalidateAuthToken(WikiAccountAuthenticator.COMMONS_ACCOUNT_TYPE, api.getAuthCookie());
- try {
- String authCookie = accountManager.blockingGetAuthToken(curAccount, "", false);
- api.setAuthCookie(authCookie);
- return true;
- } catch (OperationCanceledException e) {
- e.printStackTrace();
- return false;
- } catch (AuthenticatorException e) {
- e.printStackTrace();
- return false;
- } catch (IOException e) {
- e.printStackTrace();
- return false;
- } catch (NullPointerException e) {
- e.printStackTrace();
- return false;
- }
- }
-
- public boolean deviceHasCamera() {
- PackageManager pm = getPackageManager();
- return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) ||
- pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT);
- }
-}
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/EventLog.java b/app/src/main/java/fr/free/nrw/commons/EventLog.java
deleted file mode 100644
index 373a47ac9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/EventLog.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.SharedPreferences;
-import android.os.AsyncTask;
-import android.os.Build;
-import android.preference.PreferenceManager;
-import android.util.Log;
-
-import org.apache.http.HttpResponse;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.io.IOException;
-import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.URL;
-
-import fr.free.nrw.commons.settings.Prefs;
-import in.yuvi.http.fluent.Http;
-
-public class EventLog {
-
- private static CommonsApplication app;
-
- private static class LogTask extends AsyncTask {
-
- @Override
- protected Boolean doInBackground(LogBuilder... logBuilders) {
-
- boolean allSuccess = true;
- // Not using the default URL connection, since that seems to have different behavior than the rest of the code
- for(LogBuilder logBuilder: logBuilders) {
- HttpURLConnection conn;
- try {
- URL url = logBuilder.toUrl();
- HttpResponse response = Http.get(url.toString()).use(CommonsApplication.createHttpClient()).asResponse();
-
- if(response.getStatusLine().getStatusCode() != 204) {
- allSuccess = false;
- }
- Log.d("Commons", "EventLog hit " + url.toString());
-
- } catch (IOException e) {
- // Probably just ignore for now. Can be much more robust with a service, etc later on.
- Log.d("Commons", "IO Error, EventLog hit skipped");
- }
- }
-
- return allSuccess;
- }
- }
-
- private static final String DEVICE;
- static {
- if (Build.MODEL.startsWith(Build.MANUFACTURER)) {
- DEVICE = Utils.capitalize(Build.MODEL);
- } else {
- DEVICE = Utils.capitalize(Build.MANUFACTURER) + " " + Build.MODEL;
- }
- }
-
- public static void setApp(CommonsApplication app) {
- EventLog.app = app;
- }
-
- public static class LogBuilder {
- private JSONObject data;
- private long rev;
- private String schema;
-
- private LogBuilder(String schema, long revision) {
- data = new JSONObject();
- this.schema = schema;
- this.rev = revision;
- }
-
- public LogBuilder param(String key, Object value) {
- try {
- data.put(key, value);
- } catch (JSONException e) {
- throw new RuntimeException(e);
- }
- return this;
- }
-
- private URL toUrl() {
- JSONObject fullData = new JSONObject();
- try {
- fullData.put("schema", schema);
- fullData.put("revision", rev);
- fullData.put("wiki", CommonsApplication.EVENTLOG_WIKI);
- data.put("device", DEVICE);
- data.put("platform", "Android/" + Build.VERSION.RELEASE);
- data.put("appversion", "Android/" + BuildConfig.VERSION_NAME);
- fullData.put("event", data);
- return new URL(CommonsApplication.EVENTLOG_URL + "?" + Utils.urlEncode(fullData.toString()) + ";");
- } catch (MalformedURLException e) {
- throw new RuntimeException(e);
- } catch (JSONException e) {
- throw new RuntimeException(e);
- }
- }
-
- // force param disregards user preference
- // Use *only* for tracking the user preference change for EventLogging
- // Attempting to use anywhere else will cause kitten explosions
- public void log(boolean force) {
- SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(app);
- if(!settings.getBoolean(Prefs.TRACKING_ENABLED, true) && !force) {
- return; // User has disabled tracking
- }
- LogTask logTask = new LogTask();
- logTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, this);
- }
-
- public void log() {
- log(false);
- }
-
- }
-
- public static LogBuilder schema(String schema, long revision) {
- return new LogBuilder(schema, revision);
- }
-
- public static LogBuilder schema(Object[] scid) {
- if(scid.length != 2) {
- throw new IllegalArgumentException("Needs an object array with schema as first param and revision as second");
- }
- return schema((String)scid[0], (Long)scid[1]);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/HandlerService.java b/app/src/main/java/fr/free/nrw/commons/HandlerService.java
deleted file mode 100644
index 61fa1f1c5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/HandlerService.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.app.Service;
-import android.content.Intent;
-import android.os.Binder;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Message;
-
-public abstract class HandlerService extends Service {
- private volatile Looper threadLooper;
- private volatile ServiceHandler threadHandler;
- private String serviceName;
-
- private final class ServiceHandler extends Handler {
- public ServiceHandler(Looper looper) {
- super(looper);
- }
-
- @Override
- public void handleMessage(Message msg) {
- //FIXME: Google Photos bug
- handle(msg.what, (T)msg.obj);
- stopSelf(msg.arg1);
- }
- }
-
- @Override
- public void onDestroy() {
- threadLooper.quit();
- super.onDestroy();
- }
-
- public class HandlerServiceLocalBinder extends Binder {
- public HandlerService getService() {
- return HandlerService.this;
- }
- }
-
- private final IBinder localBinder = new HandlerServiceLocalBinder();
- @Override
- public IBinder onBind(Intent intent) {
- return localBinder;
- }
-
- protected HandlerService(String serviceName) {
- this.serviceName = serviceName;
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- HandlerThread thread = new HandlerThread(serviceName);
- thread.start();
-
- threadLooper = thread.getLooper();
- threadHandler = new ServiceHandler(threadLooper);
- }
-
- private void postMessage(int type, T t) {
- Message msg = threadHandler.obtainMessage(type);
- msg.obj = t;
- threadHandler.sendMessage(msg);
- }
-
- public void queue(int what, T t) {
- postMessage(what, t);
- }
-
- protected abstract void handle(int what, T t);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/License.java b/app/src/main/java/fr/free/nrw/commons/License.java
deleted file mode 100644
index d7b5c28e2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/License.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package fr.free.nrw.commons;
-
-public class License {
- String key;
- String template;
- String url;
- String name;
-
- public License(String key, String template, String url, String name) {
- if (key == null) {
- throw new RuntimeException("License.key must not be null");
- }
- if (template == null) {
- throw new RuntimeException("License.template must not be null");
- }
- this.key = key;
- this.template = template;
- this.url = url;
- this.name = name;
- }
-
- public String getKey() {
- return key;
- }
-
- public String getTemplate() {
- return template;
- }
-
- public String getName() {
- if (name == null) {
- // hack
- return getKey();
- } else {
- return name;
- }
- }
-
- public String getUrl(String language) {
- if (url == null) {
- return null;
- } else {
- return url.replace("$lang", language);
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/LicenseList.java b/app/src/main/java/fr/free/nrw/commons/LicenseList.java
deleted file mode 100644
index 0ef067496..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LicenseList.java
+++ /dev/null
@@ -1,78 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.app.Activity;
-import android.content.res.Resources;
-
-import org.xmlpull.v1.XmlPullParser;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-
-public class LicenseList {
- Map licenses = new HashMap<>();
- Resources res;
-
- private static String XMLNS_LICENSE = "https://www.mediawiki.org/wiki/Extension:UploadWizard/xmlns/licenses";
-
- public LicenseList(Activity activity) {
- res = activity.getResources();
- XmlPullParser parser = res.getXml(R.xml.wikimedia_licenses);
- while (Utils.xmlFastForward(parser, XMLNS_LICENSE, "license")) {
- String id = parser.getAttributeValue(null, "id");
- String template = parser.getAttributeValue(null, "template");
- String url = parser.getAttributeValue(null, "url");
- String name = nameForTemplate(template);
- License license = new License(id, template, url, name);
- licenses.put(id, license);
- }
- }
-
- public Set keySet() {
- return licenses.keySet();
- }
-
- public Collection values() {
- return licenses.values();
- }
-
- public License get(String key) {
- return licenses.get(key);
- }
-
- public License licenseForTemplate(String template) {
- String ucTemplate = Utils.capitalize(template);
- for (License license : values()) {
- if (ucTemplate.equals(Utils.capitalize(license.getTemplate()))) {
- return license;
- }
- }
- return null;
- }
-
- public String nameIdForTemplate(String template) {
- // hack :D (converts dashes and periods to underscores)
- // cc-by-sa-3.0 -> cc_by_sa_3_0
- return "license_name_" + template.toLowerCase(Locale.ENGLISH).replace("-", "_").replace(".", "_");
- }
-
- private int stringIdByName(String stringId) {
- return res.getIdentifier("fr.free.nrw.commons:string/" + stringId, null, null);
- }
-
- public String nameForTemplate(String template) {
- //Log.d("Commons", "LicenseList.nameForTemplate: template: " + template);
- String stringId = nameIdForTemplate(template);
- //Log.d("Commons", "LicenseList.nameForTemplate: stringId: " + stringId);
- int nameId = stringIdByName(stringId);
- //Log.d("Commons", "LicenseList.nameForTemplate: nameId: " + nameId);
- if(nameId != 0) {
- String name = res.getString(nameId);
- //Log.d("Commons", "LicenseList.nameForTemplate: name: " + name);
- return name;
- }
- return template;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/MapController.kt b/app/src/main/java/fr/free/nrw/commons/MapController.kt
new file mode 100644
index 000000000..5888b3f5f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MapController.kt
@@ -0,0 +1,46 @@
+package fr.free.nrw.commons
+
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Place
+
+abstract class MapController {
+ /**
+ * We pass this variable as a group of placeList and boundaryCoordinates
+ */
+ inner class NearbyPlacesInfo {
+ @JvmField
+ var placeList: List = emptyList() // List of nearby places
+
+ @JvmField
+ var boundaryCoordinates: Array = emptyArray() // Corners of nearby area
+
+ @JvmField
+ var currentLatLng: LatLng? = null // Current location when this places are populated
+
+ @JvmField
+ var searchLatLng: LatLng? = null // Search location for finding this places
+
+ @JvmField
+ var mediaList: List? = null // Search location for finding this places
+ }
+
+ /**
+ * We pass this variable as a group of placeList and boundaryCoordinates
+ */
+ inner class ExplorePlacesInfo {
+ @JvmField
+ var explorePlaceList: List = emptyList() // List of nearby places
+
+ @JvmField
+ var boundaryCoordinates: Array = emptyArray() // Corners of nearby area
+
+ @JvmField
+ var currentLatLng: LatLng? = null // Current location when this places are populated
+
+ @JvmField
+ var searchLatLng: LatLng? = null // Search location for finding this places
+
+ @JvmField
+ var mediaList: List = emptyList() // Search location for finding this places
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.java b/app/src/main/java/fr/free/nrw/commons/Media.java
deleted file mode 100644
index 924427cef..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Media.java
+++ /dev/null
@@ -1,258 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.os.Parcel;
-import android.os.Parcelable;
-
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class Media implements Parcelable {
-
- public static Creator CREATOR = new Creator() {
- @Override
- public Media createFromParcel(Parcel parcel) {
- return new Media(parcel);
- }
-
- @Override
- public Media[] newArray(int i) {
- return new Media[0];
- }
- };
-
- protected Media() {
- this.categories = new ArrayList<>();
- this.descriptions = new HashMap<>();
- }
-
- private HashMap tags = new HashMap<>();
-
- public Object getTag(String key) {
- return tags.get(key);
- }
-
- public void setTag(String key, Object value) {
- tags.put(key, value);
- }
-
- public static Pattern displayTitlePattern = Pattern.compile("(.*)(\\.\\w+)", Pattern.CASE_INSENSITIVE);
- public String getDisplayTitle() {
- if(filename == null) {
- return "";
- }
- // FIXME: Gross hack bercause my regex skills suck maybe or I am too lazy who knows
- String title = filename.replaceFirst("^File:", "");
- Matcher matcher = displayTitlePattern.matcher(title);
- if(matcher.matches()) {
- return matcher.group(1);
- } else {
- return title;
- }
- }
-
- public String getDescriptionUrl() {
- // HACK! Geez
- return CommonsApplication.HOME_URL + "File:" + Utils.urlEncode(getFilename().replace("File:", "").replace(" ", "_"));
- }
-
- public String getMobileDescriptionUrl() {
- return CommonsApplication.MOBILE_HOME_URL + "File:" + Utils.urlEncode(getFilename().replace("File:", "").replace(" ", "_"));
- }
-
- public Uri getLocalUri() {
- return localUri;
- }
-
- public String getImageUrl() {
- if(imageUrl == null) {
- imageUrl = Utils.makeThumbBaseUrl(this.getFilename());
- }
- return imageUrl;
- }
-
- public String getFilename() {
- return filename;
- }
-
- public void setFilename(String filename) {
- this.filename = filename;
- }
-
- public String getDescription() {
- return description;
- }
-
- public long getDataLength() {
- return dataLength;
- }
-
- public void setDataLength(long dataLength) {
- this.dataLength = dataLength;
- }
-
- public Date getDateCreated() {
- return dateCreated;
- }
-
- public void setDateCreated(Date date) {
- this.dateCreated = date;
- }
-
- public Date getDateUploaded() {
- return dateUploaded;
- }
-
- public String getCreator() {
- return creator;
- }
-
- public void setCreator(String creator) {
- this.creator = creator;
- }
-
- public String getThumbnailUrl(int width) {
- return Utils.makeThumbUrl(getImageUrl(), getFilename(), width);
- }
-
- public int getWidth() {
- return width;
- }
-
- public void setWidth(int width) {
- this.width = width;
- }
-
- public int getHeight() {
- return height;
- }
-
- public void setHeight(int height) {
- this.height = height;
- }
-
- public String getLicense() {
- return license;
- }
-
- public void setLicense(String license) {
- this.license = license;
- }
-
- // Primary metadata fields
- protected Uri localUri;
- protected String imageUrl;
- protected String filename;
- protected String description; // monolingual description on input...
- protected long dataLength;
- protected Date dateCreated;
- protected Date dateUploaded;
- protected int width;
- protected int height;
- protected String license;
- protected String creator;
- protected ArrayList categories; // as loaded at runtime?
- protected Map descriptions; // multilingual descriptions as loaded
-
- public ArrayList getCategories() {
- return (ArrayList)categories.clone(); // feels dirty
- }
-
- public void setCategories(List categories) {
- this.categories.removeAll(this.categories);
- this.categories.addAll(categories);
- }
-
- public void setDescriptions(Map descriptions) {
- for (String key : this.descriptions.keySet()) {
- this.descriptions.remove(key);
- }
- for (String key : descriptions.keySet()) {
- this.descriptions.put(key, descriptions.get(key));
- }
- }
-
- public String getDescription(String preferredLanguage) {
- if (descriptions.containsKey(preferredLanguage)) {
- // See if the requested language is there.
- return descriptions.get(preferredLanguage);
- } else if (descriptions.containsKey("en")) {
- // Ah, English. Language of the world, until the Chinese crush us.
- return descriptions.get("en");
- } else if (descriptions.containsKey("default")) {
- // No languages marked...
- return descriptions.get("default");
- } else {
- // FIXME: return the first available non-English description?
- return "";
- }
- }
-
- public Media(String filename) {
- this();
- this.filename = filename;
- }
-
- public Media(Uri localUri, String imageUrl, String filename, String description, long dataLength, Date dateCreated, Date dateUploaded, String creator) {
- this();
- this.localUri = localUri;
- this.imageUrl = imageUrl;
- this.filename = filename;
- this.description = description;
- this.dataLength = dataLength;
- this.dateCreated = dateCreated;
- this.dateUploaded = dateUploaded;
- this.creator = creator;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel parcel, int flags) {
- parcel.writeParcelable(localUri, flags);
- parcel.writeString(imageUrl);
- parcel.writeString(filename);
- parcel.writeString(description);
- parcel.writeLong(dataLength);
- parcel.writeSerializable(dateCreated);
- parcel.writeSerializable(dateUploaded);
- parcel.writeString(creator);
- parcel.writeSerializable(tags);
- parcel.writeInt(width);
- parcel.writeInt(height);
- parcel.writeString(license);
- parcel.writeStringList(categories);
- parcel.writeMap(descriptions);
- }
-
- public Media(Parcel in) {
- localUri = in.readParcelable(Uri.class.getClassLoader());
- imageUrl = in.readString();
- filename = in.readString();
- description = in.readString();
- dataLength = in.readLong();
- dateCreated = (Date) in.readSerializable();
- dateUploaded = (Date) in.readSerializable();
- creator = in.readString();
- tags = (HashMap)in.readSerializable();
- width = in.readInt();
- height = in.readInt();
- license = in.readString();
- if (categories != null) {
- in.readStringList(categories);
- }
- descriptions = in.readHashMap(ClassLoader.getSystemClassLoader());
- }
-
- public void setDescription(String description) {
- this.description = description;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt
new file mode 100644
index 000000000..dbe722e91
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/Media.kt
@@ -0,0 +1,206 @@
+package fr.free.nrw.commons
+
+import android.os.Parcelable
+import fr.free.nrw.commons.BuildConfig.COMMONS_URL
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.wikidata.model.WikiSite
+import fr.free.nrw.commons.wikidata.model.page.PageTitle
+import kotlinx.parcelize.IgnoredOnParcel
+import kotlinx.parcelize.Parcelize
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+
+@Parcelize
+class Media constructor(
+ /**
+ * @return pageId for the current media object
+ * Wikibase Identifier associated with media files
+ */
+ var pageId: String = UUID.randomUUID().toString(),
+ var thumbUrl: String? = null,
+ /**
+ * Gets image URL
+ * @return Image URL
+ */
+ var imageUrl: String? = null,
+ /**
+ * Gets the name of the file.
+ * @return file name as a string
+ */
+ var filename: String? = null,
+ /**
+ * The fallback description of the file, used if no other description is provided.
+ */
+ var fallbackDescription: String? = null,
+ /**
+ * Gets the upload date of the file.
+ * Can be null.
+ * @return upload date as a Date
+ */
+ var dateUploaded: Date? = null,
+ /**
+ * The license name of the file.
+ */
+ var license: String? = null,
+ /**
+ * The URL corresponding to the license.
+ */
+ var licenseUrl: String? = null,
+ /**
+ * The name of the creator of the file.
+ */
+ var author: String? = null,
+ /**
+ * The username of the uploader.
+ */
+ var user: String? = null,
+ /**
+ * The full name of the file's creator, if different from username.
+ */
+ var creatorName: String? = null,
+ /**
+ * Gets the categories the file falls under.
+ * @return file categories as an ArrayList of Strings
+ */
+ var categories: List? = null,
+ /**
+ * Gets the coordinates of where the file was created.
+ * @return file coordinates as a LatLng
+ */
+ var coordinates: LatLng? = null,
+ var captions: Map = emptyMap(),
+ var descriptions: Map = emptyMap(),
+ var depictionIds: List = emptyList(),
+ var creatorIds: List = emptyList(),
+ /**
+ * This field was added to find non-hidden categories
+ * Stores the mapping of category title to hidden attribute
+ * Example: "Mountains" => false, "CC-BY-SA-2.0" => true
+ */
+ var categoriesHiddenStatus: Map = emptyMap(),
+) : Parcelable {
+ constructor(
+ captions: Map,
+ categories: List?,
+ filename: String?,
+ fallbackDescription: String?,
+ author: String?,
+ user: String?,
+ ) : this(
+ filename = filename,
+ fallbackDescription = fallbackDescription,
+ dateUploaded = Date(),
+ author = author,
+ user = user,
+ categories = categories,
+ captions = captions,
+ )
+
+ constructor(
+ captions: Map,
+ categories: List?,
+ filename: String?,
+ fallbackDescription: String?,
+ author: String?,
+ user: String?,
+ dateUploaded: Date? = Date(),
+ license: String? = null,
+ licenseUrl: String? = null,
+ imageUrl: String? = null,
+ thumbUrl: String? = null,
+ coordinates: LatLng? = null,
+ descriptions: Map = emptyMap(),
+ depictionIds: List = emptyList(),
+ categoriesHiddenStatus: Map = emptyMap()
+ ) : this(
+ pageId = UUID.randomUUID().toString(),
+ filename = filename,
+ fallbackDescription = fallbackDescription,
+ dateUploaded = dateUploaded,
+ author = author,
+ user = user,
+ categories = categories,
+ captions = captions,
+ license = license,
+ licenseUrl = licenseUrl,
+ imageUrl = imageUrl,
+ thumbUrl = thumbUrl,
+ coordinates = coordinates,
+ descriptions = descriptions,
+ depictionIds = depictionIds,
+ categoriesHiddenStatus = categoriesHiddenStatus
+ )
+
+ /**
+ * Returns Author if it's not null or empty, otherwise
+ * returns user
+ * @return Author or User
+ */
+ @Deprecated("Use user for uploader username. Use attributedAuthor() for attribution. Note that the uploader may not be the creator/author.")
+ fun getAuthorOrUser(): String? {
+ return if (!author.isNullOrEmpty()) {
+ author
+ } else{
+ user
+ }
+ }
+
+ /**
+ * Returns author if it's not null or empty, otherwise
+ * returns creator name
+ * @return name of author or creator
+ */
+ fun getAttributedAuthor(): String? {
+ return if (!author.isNullOrEmpty()) {
+ author
+ } else{
+ creatorName
+ }
+ }
+
+ /**
+ * Gets media display title
+ * @return Media title
+ */
+ val displayTitle: String
+ get() =
+ if (filename != null) {
+ pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
+ } else {
+ ""
+ }
+
+ /**
+ * Gets file page title
+ * @return New media page title
+ */
+ val pageTitle: PageTitle
+ get() = PageTitle(filename!!, WikiSite(COMMONS_URL))
+
+ /**
+ * Returns wikicode to use the media file on a MediaWiki site
+ * @return
+ */
+ val wikiCode: String
+ get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
+
+ val mostRelevantCaption: String
+ get() =
+ captions[Locale.getDefault().language]
+ ?: captions.values.firstOrNull()
+ ?: displayTitle
+
+ /**
+ * Gets the categories the file falls under.
+ * @return file categories as an ArrayList of Strings
+ */
+ @IgnoredOnParcel
+ var addedCategories: List? = null
+ // TODO added categories should be removed. It is added for a short fix. On category update,
+ // categories should be re-fetched instead
+ get() = field // getter
+ set(value) {
+ field = value
+ } // setter
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
deleted file mode 100644
index f5d153e5a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
+++ /dev/null
@@ -1,299 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.util.Log;
-
-import org.mediawiki.api.ApiResult;
-import org.mediawiki.api.MWApi;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
-import org.xml.sax.SAXException;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.DocumentBuilderFactory;
-import javax.xml.parsers.ParserConfigurationException;
-
-/**
- * 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.
- */
-public class MediaDataExtractor {
- private boolean fetched;
- private boolean processed;
-
- private String filename;
- private ArrayList categories;
- private Map descriptions;
- private String author;
- private Date date;
- private String license;
- private LicenseList licenseList;
-
- /**
- * @param filename of the target media object, should include 'File:' prefix
- */
- public MediaDataExtractor(String filename, LicenseList licenseList) {
- this.filename = filename;
- categories = new ArrayList<>();
- descriptions = new HashMap<>();
- fetched = false;
- processed = false;
- this.licenseList = licenseList;
- }
-
- /**
- * Actually fetch the data over the network.
- * todo: use local caching?
- *
- * Warning: synchronous i/o, call on a background thread
- */
- public void fetch() throws IOException {
- if (fetched) {
- throw new IllegalStateException("Tried to call MediaDataExtractor.fetch() again.");
- }
-
- MWApi api = CommonsApplication.createMWApi();
- ApiResult result = api.action("query")
- .param("prop", "revisions")
- .param("titles", filename)
- .param("rvprop", "content")
- .param("rvlimit", 1)
- .param("rvgeneratexml", 1)
- .get();
-
- processResult(result);
- fetched = true;
- }
-
- private void processResult(ApiResult result) throws IOException {
-
- String wikiSource = result.getString("/api/query/pages/page/revisions/rev");
- String parseTreeXmlSource = result.getString("/api/query/pages/page/revisions/rev/@parsetree");
-
- // In-page category links are extracted from source, as XML doesn't cover [[links]]
- extractCategories(wikiSource);
-
- // Description template info is extracted from preprocessor XML
- processWikiParseTree(parseTreeXmlSource);
- }
-
- /**
- * We could fetch all category links from API, but we actually only want the ones
- * directly in the page source so they're editable. In the future this may change.
- *
- * @param source wikitext source code
- */
- private void extractCategories(String source) {
- Pattern regex = Pattern.compile("\\[\\[\\s*Category\\s*:([^]]*)\\s*\\]\\]", Pattern.CASE_INSENSITIVE);
- Matcher matcher = regex.matcher(source);
- while (matcher.find()) {
- String cat = matcher.group(1).trim();
- categories.add(cat);
- }
- }
-
- private void processWikiParseTree(String source) throws IOException {
- Document doc;
- try {
- DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
- doc = docBuilder.parse(new ByteArrayInputStream(source.getBytes("UTF-8")));
- } catch (ParserConfigurationException e) {
- throw new RuntimeException(e);
- } catch (IllegalStateException e) {
- throw new IOException(e);
- } catch (SAXException e) {
- throw new IOException(e);
- }
- Node templateNode = findTemplate(doc.getDocumentElement(), "information");
- if (templateNode != null) {
- Node descriptionNode = findTemplateParameter(templateNode, "description");
- descriptions = getMultilingualText(descriptionNode);
-
- Node authorNode = findTemplateParameter(templateNode, "author");
- author = getFlatText(authorNode);
- }
-
- /*
- Pull up the license data list...
- look for the templates in two ways:
- * look for 'self' template and check its first parameter
- * if none, look for any of the known templates
- */
- Log.d("Commons", "MediaDataExtractor searching for license");
- Node selfLicenseNode = findTemplate(doc.getDocumentElement(), "self");
- if (selfLicenseNode != null) {
- Node firstNode = findTemplateParameter(selfLicenseNode, 1);
- String licenseTemplate = getFlatText(firstNode);
- License license = licenseList.licenseForTemplate(licenseTemplate);
- if (license == null) {
- Log.d("Commons", "MediaDataExtractor found no matching license for self parameter: " + licenseTemplate + "; faking it");
- this.license = licenseTemplate; // hack hack! For non-selectable licenses that are still in the system.
- } else {
- // fixme: record the self-ness in here too... sigh
- // all this needs better server-side metadata
- this.license = license.getKey();
- Log.d("Commons", "MediaDataExtractor found self-license " + this.license);
- }
- } else {
- for (License license : licenseList.values()) {
- String templateName = license.getTemplate();
- Node template = findTemplate(doc.getDocumentElement(), templateName);
- if (template != null) {
- // Found!
- this.license = license.getKey();
- Log.d("Commons", "MediaDataExtractor found non-self license " + this.license);
- break;
- }
- }
- }
- }
-
- private Node findTemplate(Element parentNode, String title) throws IOException {
- String ucTitle= Utils.capitalize(title);
- NodeList nodes = parentNode.getChildNodes();
- for (int i = 0, length = nodes.getLength(); i < length; i++) {
- Node node = nodes.item(i);
- if (node.getNodeName().equals("template")) {
- String foundTitle = getTemplateTitle(node);
- if (Utils.capitalize(foundTitle).equals(ucTitle)) {
- return node;
- }
- }
- }
- return null;
- }
-
- private String getTemplateTitle(Node templateNode) throws IOException {
- NodeList nodes = templateNode.getChildNodes();
- for (int i = 0, length = nodes.getLength(); i < length; i++) {
- Node node = nodes.item(i);
- if (node.getNodeName().equals("title")) {
- return node.getTextContent().trim();
- }
- }
- throw new IOException("Template has no title element.");
- }
-
- private static abstract class TemplateChildNodeComparator {
- abstract public boolean match(Node node);
- }
-
- private Node findTemplateParameter(Node templateNode, String name) throws IOException {
- final String theName = name;
- return findTemplateParameter(templateNode, new TemplateChildNodeComparator() {
- @Override
- public boolean match(Node node) {
- return (Utils.capitalize(node.getTextContent().trim()).equals(Utils.capitalize(theName)));
- }
- });
- }
-
- private Node findTemplateParameter(Node templateNode, int index) throws IOException {
- final String theIndex = "" + index;
- return findTemplateParameter(templateNode, new TemplateChildNodeComparator() {
- @Override
- public boolean match(Node node) {
- Element el = (Element)node;
- if (el.getTextContent().trim().equals(theIndex)) {
- return true;
- } else if (el.getAttribute("index") != null && el.getAttribute("index").trim().equals(theIndex)) {
- return true;
- } else {
- return false;
- }
- }
- });
- }
-
- private Node findTemplateParameter(Node templateNode, TemplateChildNodeComparator comparator) throws IOException {
- NodeList nodes = templateNode.getChildNodes();
- for (int i = 0, length = nodes.getLength(); i < length; i++) {
- Node node = nodes.item(i);
- if (node.getNodeName().equals("part")) {
- NodeList childNodes = node.getChildNodes();
- for (int j = 0, childNodesLength = childNodes.getLength(); j < childNodesLength; j++) {
- Node childNode = childNodes.item(j);
- if (childNode.getNodeName().equals("name") && comparator.match(childNode)) {
- // yay! Now fetch the value node.
- for (int k = j + 1; k < childNodesLength; k++) {
- Node siblingNode = childNodes.item(k);
- if (siblingNode.getNodeName().equals("value")) {
- return siblingNode;
- }
- }
- throw new IOException("No value node found for matched template parameter.");
- }
- }
- }
- }
- throw new IOException("No matching template parameter node found.");
- }
-
- private String getFlatText(Node parentNode) throws IOException {
- return parentNode.getTextContent();
- }
-
- // Extract a dictionary of multilingual texts from a subset of the parse tree.
- // Texts are wrapped in things like {{en|foo} or {{en|1=foo bar}}.
- // Text outside those wrappers is stuffed into a 'default' faux language key if present.
- private Map getMultilingualText(Node parentNode) throws IOException {
- Map texts = new HashMap<>();
- StringBuilder localText = new StringBuilder();
-
- NodeList nodes = parentNode.getChildNodes();
- for (int i = 0, length = nodes.getLength(); i < length; i++) {
- Node node = nodes.item(i);
- if (node.getNodeName().equals("template")) {
- // process a template node
- String title = getTemplateTitle(node);
- if (title.length() < 3) {
- // Hopefully a language code. Nasty hack!
- String lang = title;
- Node valueNode = findTemplateParameter(node, 1);
- String value = valueNode.getTextContent(); // hope there's no subtemplates or formatting for now
- texts.put(lang, value);
- }
- } else if (node.getNodeType() == Node.TEXT_NODE) {
- localText.append(node.getTextContent());
- }
- }
-
- // Some descriptions don't list multilingual variants
- String defaultText = localText.toString().trim();
- if (defaultText.length() > 0) {
- texts.put("default", localText.toString());
- }
- return texts;
- }
-
- /**
- * Take our metadata and inject it into a live Media object.
- * Media object might contain stale or cached data, or emptiness.
- * @param media
- */
- public void fill(Media media) {
- if (!fetched) {
- throw new IllegalStateException("Tried to call MediaDataExtractor.fill() before fetch().");
- }
-
- media.setCategories(categories);
- media.setDescriptions(descriptions);
- if (license != null) {
- media.setLicense(license);
- }
-
- // add author, date, etc fields
- }
-}
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/MediaWikiImageView.java b/app/src/main/java/fr/free/nrw/commons/MediaWikiImageView.java
deleted file mode 100644
index 02b69b260..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaWikiImageView.java
+++ /dev/null
@@ -1,227 +0,0 @@
-/**
- * Copyright (C) 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.
- */
-package fr.free.nrw.commons;
-
-import android.content.Context;
-import android.content.res.TypedArray;
-import android.graphics.drawable.BitmapDrawable;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.view.View;
-import android.widget.ImageView;
-
-import com.android.volley.VolleyError;
-import com.android.volley.toolbox.ImageLoader;
-import com.android.volley.toolbox.ImageLoader.ImageContainer;
-import com.android.volley.toolbox.ImageLoader.ImageListener;
-
-import fr.free.nrw.commons.contributions.Contribution;
-import fr.free.nrw.commons.contributions.ContributionsContentProvider;
-
-public class MediaWikiImageView extends ImageView {
-
- private Media mMedia;
-
- private ImageLoader mImageLoader;
-
- private ImageContainer mImageContainer;
-
- private View loadingView;
-
- private boolean isThumbnail;
-
- public MediaWikiImageView(Context context) {
- this(context, null);
- }
-
- public MediaWikiImageView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- TypedArray actualAttrs = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MediaWikiImageView, 0, 0);
- isThumbnail = actualAttrs.getBoolean(0, false);
- actualAttrs.recycle();
- }
-
- public MediaWikiImageView(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- }
-
- public void setMedia(Media media, ImageLoader imageLoader) {
- this.mMedia = media;
- mImageLoader = imageLoader;
- loadImageIfNecessary(false);
- }
-
- public void setLoadingView(View loadingView) {
- this.loadingView = loadingView;
- }
-
- public View getLoadingView() {
- return loadingView;
- }
-
- private void loadImageIfNecessary(final boolean isInLayoutPass) {
- loadImageIfNecessary(isInLayoutPass, false);
- }
-
- private void loadImageIfNecessary(final boolean isInLayoutPass, final boolean tryOriginal) {
- int width = getWidth();
- int height = getHeight();
-
- // if the view's bounds aren't known yet, hold off on loading the image.
- if (width == 0 && height == 0) {
- return;
- }
-
- if(mMedia == null) {
- return;
- }
-
- // Do not count for density when loading thumbnails.
- // FIXME: Use another 'algorithm' that doesn't punish low res devices
- if(isThumbnail) {
- float dpFactor = Math.max(getResources().getDisplayMetrics().density, 1.0f);
- width = (int) (width / dpFactor);
- height = (int) (height / dpFactor);
- }
-
- final String mUrl;
- if(tryOriginal) {
- mUrl = mMedia.getImageUrl();
- } else {
- // Round it to the nearest 320
- // Possible a similar size image has already been generated.
- // Reduces Server cache fragmentation, also increases chance of cache hit
- // If width is less than 320, we round up to 320
- int bucketedWidth = width <= 320 ? 320 : Math.round((float)width / 320.0f) * 320;
- if(mMedia.getWidth() != 0 && mMedia.getWidth() < bucketedWidth) {
- // If we know that the width of the image is lesser than the required width
- // We don't even try to load the thumbnai, go directly to the source
- loadImageIfNecessary(isInLayoutPass, true);
- return;
- } else {
- mUrl = mMedia.getThumbnailUrl(bucketedWidth);
- }
- }
-
- // if the URL to be loaded in this view is empty, cancel any old requests and clear the
- // currently loaded image.
- if (TextUtils.isEmpty(mUrl)) {
- if (mImageContainer != null) {
- mImageContainer.cancelRequest();
- mImageContainer = null;
- }
- setImageBitmap(null);
- return;
- }
-
- // Don't repeat work. Prevents onLayout cascades
- // We ignore it if the image request was for either the current URL of for the full URL
- // Since the full URL is always the second, and
- if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
- if (mImageContainer.getRequestUrl().equals(mMedia.getImageUrl()) || mImageContainer.getRequestUrl().equals(mUrl)) {
- return;
- } else {
- // if there is a pre-existing request, cancel it if it's fetching a different URL.
- mImageContainer.cancelRequest();
- BitmapDrawable actualDrawable = (BitmapDrawable)getDrawable();
- if(actualDrawable != null && actualDrawable.getBitmap() != null) {
- setImageBitmap(null);
- if(loadingView != null) {
- loadingView.setVisibility(View.VISIBLE);
- }
- }
- }
- }
-
- // The pre-existing content of this view didn't match the current URL. Load the new image
- // from the network.
- ImageContainer newContainer = mImageLoader.get(mUrl,
- new ImageListener() {
- @Override
- public void onErrorResponse(final VolleyError error) {
- if(!tryOriginal) {
- post(new Runnable() {
- @Override
- public void run() {
- loadImageIfNecessary(false, true);
- }
- });
- }
- }
-
- @Override
- public void onResponse(final ImageContainer response, boolean isImmediate) {
- // If this was an immediate response that was delivered inside of a layout
- // pass do not set the image immediately as it will trigger a requestLayout
- // inside of a layout. Instead, defer setting the image by posting back to
- // the main thread.
- if (isImmediate && isInLayoutPass) {
- post(new Runnable() {
- @Override
- public void run() {
- onResponse(response, false);
- }
- });
- return;
- }
-
- if (response.getBitmap() != null) {
- setImageBitmap(response.getBitmap());
- if(tryOriginal && mMedia instanceof Contribution && (response.getBitmap().getWidth() > mMedia.getWidth() || response.getBitmap().getHeight() > mMedia.getHeight())) {
- // If there is no width information for this image, save it. This speeds up image loading massively for smaller images
- mMedia.setHeight(response.getBitmap().getHeight());
- mMedia.setWidth(response.getBitmap().getWidth());
- ((Contribution)mMedia).setContentProviderClient(MediaWikiImageView.this.getContext().getContentResolver().acquireContentProviderClient(ContributionsContentProvider.AUTHORITY));
- ((Contribution)mMedia).save();
- }
- if(loadingView != null) {
- loadingView.setVisibility(View.GONE);
- }
- } else {
- // I'm not really sure where this would hit but not onError
- }
- }
- });
-
- // update the ImageContainer to be the new bitmap container.
- mImageContainer = newContainer;
- }
-
- @Override
- protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
- super.onLayout(changed, left, top, right, bottom);
- loadImageIfNecessary(true);
- }
-
- @Override
- protected void onDetachedFromWindow() {
- if (mImageContainer != null) {
- // If the view was bound to an image request, cancel it and clear
- // out the image from the view.
- mImageContainer.cancelRequest();
- setImageBitmap(null);
- // also clear out the container so we can reload the image if necessary.
- mImageContainer = null;
- }
- super.onDetachedFromWindow();
- }
-
- @Override
- protected void drawableStateChanged() {
- super.drawableStateChanged();
- invalidate();
- }
-}
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/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java
deleted file mode 100644
index 0433958fa..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Utils.java
+++ /dev/null
@@ -1,303 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.os.Build;
-import android.util.Log;
-
-import com.nostra13.universalimageloader.core.DisplayImageOptions;
-import com.nostra13.universalimageloader.core.assist.ImageScaleType;
-import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
-
-import fr.free.nrw.commons.settings.Prefs;
-
-import java.io.BufferedInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.StringWriter;
-import java.io.UnsupportedEncodingException;
-import java.math.BigInteger;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Locale;
-import java.util.TimeZone;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import javax.xml.transform.Transformer;
-import javax.xml.transform.TransformerConfigurationException;
-import javax.xml.transform.TransformerException;
-import javax.xml.transform.TransformerFactory;
-import javax.xml.transform.TransformerFactoryConfigurationError;
-import javax.xml.transform.dom.DOMSource;
-import javax.xml.transform.stream.StreamResult;
-
-import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.codec.digest.DigestUtils;
-import org.apache.commons.codec.net.URLCodec;
-import org.w3c.dom.Node;
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
-
-
-public class Utils {
-
- private static final String TAG = Utils.class.getName();
-
- // Get SHA1 of file from input stream
- public static String getSHA1(InputStream is) {
-
- MessageDigest digest;
- try {
- digest = MessageDigest.getInstance("SHA1");
- } catch (NoSuchAlgorithmException e) {
- Log.e(TAG, "Exception while getting Digest", e);
- return "";
- }
-
- byte[] buffer = new byte[8192];
- int read;
- try {
- while ((read = is.read(buffer)) > 0) {
- digest.update(buffer, 0, read);
- }
- byte[] md5sum = digest.digest();
- BigInteger bigInt = new BigInteger(1, md5sum);
- String output = bigInt.toString(16);
- // Fill to 40 chars
- output = String.format("%40s", output).replace(' ', '0');
- Log.i(TAG, "File SHA1: " + output);
-
- return output;
- } catch (IOException e) {
- Log.e(TAG, "IO Exception", e);
- return "";
- } finally {
- try {
- is.close();
- } catch (IOException e) {
- Log.e(TAG, "Exception on closing MD5 input stream", e);
- }
- }
- }
-
- /**
- * Strips localization symbols from a string.
- * Removes the suffix after "@" and quotes.
- *
- * @param s string possibly containing localization symbols
- * @return stripped string
- */
- public static String stripLocalizedString(String s) {
- Matcher matcher = Pattern.compile("\\\"(.*)\\\"(@\\w+)?").matcher(s);
- if (matcher.find()) {
- return matcher.group(1);
- } else {
- return s;
- }
- }
-
- public static Date parseMWDate(String mwDate) {
- SimpleDateFormat isoFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH); // Assuming MW always gives me UTC
- try {
- return isoFormat.parse(mwDate);
- } catch (ParseException e) {
- throw new RuntimeException(e);
- }
- }
-
- public static String toMWDate(Date date) {
- SimpleDateFormat isoFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH); // Assuming MW always gives me UTC
- isoFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
- return isoFormat.format(date);
- }
-
- public static String makeThumbBaseUrl(String filename) {
- String name = filename.replaceFirst("File:", "").replace(" ", "_");
- String sha = new String(Hex.encodeHex(DigestUtils.md5(name)));
- return String.format("%s/%s/%s/%s", CommonsApplication.IMAGE_URL_BASE, sha.substring(0, 1), sha.substring(0, 2), urlEncode(name));
- }
-
- public static String getStringFromDOM(Node dom) {
- Transformer transformer = null;
- try {
- transformer = TransformerFactory.newInstance().newTransformer();
- } catch (TransformerConfigurationException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- } catch (TransformerFactoryConfigurationError e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
-
- StringWriter outputStream = new StringWriter();
- DOMSource domSource = new DOMSource(dom);
- StreamResult strResult = new StreamResult(outputStream);
-
- try {
- transformer.transform(domSource, strResult);
- } catch (TransformerException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- }
- return outputStream.toString();
- }
-
- private static DisplayImageOptions.Builder defaultImageOptionsBuilder;
-
- public static DisplayImageOptions.Builder getGenericDisplayOptions() {
- if (defaultImageOptionsBuilder == null) {
- defaultImageOptionsBuilder = new DisplayImageOptions.Builder().cacheInMemory()
- .imageScaleType(ImageScaleType.IN_SAMPLE_POWER_OF_2);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
- // List views flicker badly during data updates on Android 2.3; we
- // haven't quite figured out why but cells seem to be rearranged oddly.
- // Disable the fade-in on 2.3 to reduce the effect.
- defaultImageOptionsBuilder = defaultImageOptionsBuilder
- .displayer(new FadeInBitmapDisplayer(300));
- }
- defaultImageOptionsBuilder = defaultImageOptionsBuilder
- .cacheInMemory()
- .resetViewBeforeLoading();
- }
- return defaultImageOptionsBuilder;
- }
-
- private static final URLCodec urlCodec = new URLCodec();
-
- public static String urlEncode(String url) {
- try {
- return urlCodec.encode(url, "utf-8");
- } catch (UnsupportedEncodingException e) {
- throw new RuntimeException(e);
- }
- }
-
- public static long countBytes(InputStream stream) throws IOException {
- long count = 0;
- BufferedInputStream bis = new BufferedInputStream(stream);
- while (bis.read() != -1) {
- count++;
- }
- return count;
- }
-
- public static String makeThumbUrl(String imageUrl, String filename, int width) {
- // Ugly Hack!
- // Update: OH DEAR GOD WHAT A HORRIBLE HACK I AM SO SORRY
- if (imageUrl.endsWith("webm")) {
- return imageUrl.replaceFirst("test/", "test/thumb/").replace("commons/", "commons/thumb/") + "/" + width + "px--" + filename.replaceAll("File:", "").replaceAll(" ", "_") + ".jpg";
- } else {
- String thumbUrl = imageUrl.replaceFirst("test/", "test/thumb/").replace("commons/", "commons/thumb/") + "/" + width + "px-" + filename.replaceAll("File:", "").replaceAll(" ", "_");
- if (thumbUrl.endsWith("jpg") || thumbUrl.endsWith("png") || thumbUrl.endsWith("jpeg")) {
- return thumbUrl;
- } else {
- return thumbUrl + ".png";
- }
- }
- }
-
- public static String capitalize(String string) {
- return string.substring(0, 1).toUpperCase(Locale.getDefault()) + string.substring(1);
- }
-
- public static String licenseTemplateFor(String license) {
- if (license.equals(Prefs.Licenses.CC_BY_3)) {
- return "{{self|cc-by-3.0}}";
- } else if (license.equals(Prefs.Licenses.CC_BY_4)) {
- return "{{self|cc-by-4.0}}";
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_3)) {
- return "{{self|cc-by-sa-3.0}}";
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_4)) {
- return "{{self|cc-by-sa-4.0}}";
- } else if (license.equals(Prefs.Licenses.CC0)) {
- return "{{self|cc-zero}}";
- }
- throw new RuntimeException("Unrecognized license value");
- }
-
- public static int licenseNameFor(String license) {
- if (license.equals(Prefs.Licenses.CC_BY_3)) {
- return R.string.license_name_cc_by;
- } else if (license.equals(Prefs.Licenses.CC_BY_4)) {
- return R.string.license_name_cc_by_four;
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_3)) {
- return R.string.license_name_cc_by_sa;
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_4)) {
- return R.string.license_name_cc_by_sa_four;
- } else if (license.equals(Prefs.Licenses.CC0)) {
- return R.string.license_name_cc0;
- }
- throw new RuntimeException("Unrecognized license value");
- }
-
- public static String licenseUrlFor(String license) {
- if (license.equals(Prefs.Licenses.CC_BY_3)) {
- return "https://creativecommons.org/licenses/by/3.0/";
- } else if (license.equals(Prefs.Licenses.CC_BY_4)) {
- return "https://creativecommons.org/licenses/by/4.0/";
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_3)) {
- return "https://creativecommons.org/licenses/by-sa/3.0/";
- } else if (license.equals(Prefs.Licenses.CC_BY_SA_4)) {
- return "https://creativecommons.org/licenses/by-sa/4.0/";
- } else if (license.equals(Prefs.Licenses.CC0)) {
- return "https://creativecommons.org/publicdomain/zero/1.0/";
- }
- throw new RuntimeException("Unrecognized license value");
- }
-
- public static Uri uriForWikiPage(String name) {
- String underscored = name.trim().replace(" ", "_");
- String uriStr = CommonsApplication.HOME_URL + urlEncode(underscored);
- return Uri.parse(uriStr);
- }
-
- /**
- * Fast-forward an XmlPullParser to the next instance of the given element
- * in the input stream (namespaced).
- *
- * @param parser
- * @param namespace
- * @param element
- * @return true on match, false on failure
- */
- public static boolean xmlFastForward(XmlPullParser parser, String namespace, String element) {
- try {
- while (parser.next() != XmlPullParser.END_DOCUMENT) {
- if (parser.getEventType() == XmlPullParser.START_TAG &&
- parser.getNamespace().equals(namespace) &&
- parser.getName().equals(element)) {
- // We found it!
- return true;
- }
- }
- return false;
- } catch (XmlPullParserException e) {
- e.printStackTrace();
- return false;
- } catch (IOException e) {
- e.printStackTrace();
- return false;
- }
- }
-
- public static String fixExtension(String title, String extension) {
- Pattern jpegPattern = Pattern.compile("\\.jpeg$", Pattern.CASE_INSENSITIVE);
-
- // People are used to ".jpg" more than ".jpeg" which the system gives us.
- if (extension != null && extension.toLowerCase(Locale.ENGLISH).equals("jpeg")) {
- extension = "jpg";
- }
- title = jpegPattern.matcher(title).replaceFirst(".jpg");
- if (extension != null && !title.toLowerCase(Locale.getDefault()).endsWith("." + extension.toLowerCase(Locale.ENGLISH))) {
- title += "." + extension;
- }
- return title;
- }
-
- public static boolean isNullOrWhiteSpace(String value) {
- return value == null || value.trim().isEmpty();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/ViewHolder.java b/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
deleted file mode 100644
index 7181d85cc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-
-public interface ViewHolder {
- void bindModel(Context context, T model);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt
new file mode 100644
index 000000000..a8ce8c79a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt
@@ -0,0 +1,44 @@
+package fr.free.nrw.commons
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import java.util.Locale
+
+/**
+ * This adapter will be used to display fragments in a ViewPager
+ */
+class ViewPagerAdapter : FragmentPagerAdapter {
+ private val context: Context
+ private var fragmentList: List = emptyList()
+ private var fragmentTitleList: List = emptyList()
+
+ constructor(context: Context, manager: FragmentManager) : super(manager) {
+ this.context = context
+ }
+
+ constructor(context: Context, manager: FragmentManager, behavior: Int) : super(manager, behavior) {
+ this.context = context
+ }
+
+ override fun getItem(position: Int): Fragment = fragmentList[position]
+
+ override fun getPageTitle(position: Int): CharSequence = fragmentTitleList[position]
+
+ override fun getCount(): Int = fragmentList.size
+
+ fun setTabs(vararg titlesToFragments: Pair) {
+ // Enforce that every title must come from strings.xml and all will consistently be uppercase
+ fragmentTitleList = titlesToFragments.map {
+ context.getString(it.first).uppercase(Locale.ROOT)
+ }
+ fragmentList = titlesToFragments.map { it.second }
+ }
+
+ companion object {
+ // Convenience method for Java callers, can be removed when everything is migrated
+ @JvmStatic
+ fun pairOf(first: Int, second: Fragment) = first to second
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
deleted file mode 100644
index 30c8baf84..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.os.Bundle;
-import android.support.v4.view.ViewPager;
-
-import com.viewpagerindicator.CirclePageIndicator;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.theme.BaseActivity;
-
-public class WelcomeActivity extends BaseActivity {
- private WelcomePagerAdapter adapter;
-
- @BindView(R.id.welcomePager) ViewPager pager;
- @BindView(R.id.welcomePagerIndicator) CirclePageIndicator indicator;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_welcome);
-
- getSupportActionBar().hide();
- ButterKnife.bind(this);
-
- setUpAdapter();
- }
-
- private void setUpAdapter() {
- adapter = new WelcomePagerAdapter(this);
- pager.setAdapter(adapter);
- indicator.setViewPager(pager);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt
new file mode 100644
index 000000000..0882ba117
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt
@@ -0,0 +1,80 @@
+package fr.free.nrw.commons
+
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import fr.free.nrw.commons.databinding.ActivityWelcomeBinding
+import fr.free.nrw.commons.databinding.PopupForCopyrightBinding
+import fr.free.nrw.commons.quiz.QuizActivity
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+
+class WelcomeActivity : BaseActivity() {
+ private var binding: ActivityWelcomeBinding? = null
+ private var isQuiz = false
+
+ /**
+ * Initialises exiting fields and dependencies
+ *
+ * @param savedInstanceState WelcomeActivity bundled data
+ */
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityWelcomeBinding.inflate(layoutInflater)
+ applyEdgeToEdgeAllInsets(binding!!.welcomePager.rootView)
+ setContentView(binding!!.root)
+
+ isQuiz = intent?.extras?.getBoolean("isQuiz", false) ?: false
+
+ // Enable skip button if beta flavor
+ if (isBetaFlavour) {
+ binding!!.finishTutorialButton.visibility = View.VISIBLE
+
+ val copyrightBinding = PopupForCopyrightBinding.inflate(layoutInflater)
+
+ val dialog = AlertDialog.Builder(this)
+ .setView(copyrightBinding.root)
+ .setCancelable(false)
+ .create()
+ dialog.show()
+
+ copyrightBinding.buttonOk.setOnClickListener { v: View? -> dialog.dismiss() }
+ }
+
+ val adapter = WelcomePagerAdapter()
+ binding!!.welcomePager.adapter = adapter
+ binding!!.welcomePagerIndicator.setViewPager(binding!!.welcomePager)
+ binding!!.finishTutorialButton.setOnClickListener { v: View? -> finishTutorial() }
+ }
+
+ public override fun onDestroy() {
+ if (isQuiz) {
+ startActivity(Intent(this, QuizActivity::class.java))
+ }
+ super.onDestroy()
+ }
+
+ override fun onBackPressed() {
+ if (binding!!.welcomePager.currentItem != 0) {
+ binding!!.welcomePager.setCurrentItem(binding!!.welcomePager.currentItem - 1, true)
+ } else {
+ if (defaultKvStore.getBoolean("firstrun", true)) {
+ finishAffinity()
+ } else {
+ super.onBackPressed()
+ }
+ }
+ }
+
+ fun finishTutorial() {
+ defaultKvStore.putBoolean("firstrun", false)
+ finish()
+ }
+}
+
+fun Context.startWelcome() {
+ startActivity(Intent(this, WelcomeActivity::class.java))
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
deleted file mode 100644
index 6da880694..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.app.Activity;
-import android.content.Context;
-import android.support.v4.view.PagerAdapter;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-
-public class WelcomePagerAdapter extends PagerAdapter {
-
- private Context context;
-
- private static final int PAGE_FINAL = 4;
-
- static final int[] PAGE_LAYOUTS = new int[]{
- R.layout.welcome_wikipedia,
- R.layout.welcome_do_upload,
- R.layout.welcome_dont_upload,
- R.layout.welcome_image_details,
- R.layout.welcome_final
- };
-
- public WelcomePagerAdapter(Context context) {
- this.context = context;
- }
-
- @Override
- public int getCount() {
- return PAGE_LAYOUTS.length;
- }
-
- @Override
- public boolean isViewFromObject(View view, Object object) {
- return (view == object);
- }
-
- @Override
- public Object instantiateItem(ViewGroup container, int position) {
- LayoutInflater inflater = LayoutInflater.from(context);
- ViewGroup layout = (ViewGroup) inflater.inflate(PAGE_LAYOUTS[position], container, false);
-
- if (position == PAGE_FINAL) {
- ViewHolder holder = new ViewHolder(layout, context);
- layout.setTag(holder);
- }
- container.addView(layout);
- return layout;
- }
-
- @Override
- public void destroyItem(ViewGroup container, int position, Object obj) {
- container.removeView((View) obj);
- }
-
- public static class ViewHolder {
- private Context context;
-
- public ViewHolder(View view, Context context) {
- ButterKnife.bind(this, view);
- this.context = context;
- }
-
- @OnClick(R.id.welcomeYesButton)
- void onClicked() {
- ((Activity) context).finish();
- }
- }
-}
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/AuthenticatedActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
deleted file mode 100644
index 6925699a1..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
+++ /dev/null
@@ -1,148 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.accounts.AccountManagerFuture;
-import android.accounts.AuthenticatorException;
-import android.accounts.OperationCanceledException;
-import android.os.AsyncTask;
-import android.os.Bundle;
-
-import java.io.IOException;
-
-import fr.free.nrw.commons.theme.BaseActivity;
-import fr.free.nrw.commons.CommonsApplication;
-
-public abstract class AuthenticatedActivity extends BaseActivity {
-
- String accountType;
- CommonsApplication app;
-
- private String authCookie;
-
- public AuthenticatedActivity(String accountType) {
- this.accountType = accountType;
- }
-
- private class GetAuthCookieTask extends AsyncTask {
- private Account account;
- private AccountManager accountManager;
- public GetAuthCookieTask(Account account, AccountManager accountManager) {
- this.account = account;
- this.accountManager = accountManager;
- }
-
- @Override
- protected void onPostExecute(String result) {
- super.onPostExecute(result);
- if(result != null) {
- authCookie = result;
- onAuthCookieAcquired(result);
- } else {
- onAuthFailure();
- }
- }
-
- @Override
- protected String doInBackground(Void... params) {
- try {
- return accountManager.blockingGetAuthToken(account, "", false);
- } catch (OperationCanceledException e) {
- e.printStackTrace();
- return null;
- } catch (AuthenticatorException e) {
- e.printStackTrace();
- return null;
- } catch (IOException e) {
- e.printStackTrace();
- return null;
- }
- }
- }
-
- private class AddAccountTask extends AsyncTask {
- private AccountManager accountManager;
- public AddAccountTask(AccountManager accountManager) {
- this.accountManager = accountManager;
- }
-
- @Override
- protected void onPostExecute(String result) {
- super.onPostExecute(result);
- if(result != null) {
- Account[] allAccounts =accountManager.getAccountsByType(accountType);
- Account curAccount = allAccounts[0];
- GetAuthCookieTask getCookieTask = new GetAuthCookieTask(curAccount, accountManager);
- getCookieTask.execute();
- } else {
- onAuthFailure();
- }
- }
-
- @Override
- protected String doInBackground(Void... params) {
- AccountManagerFuture resultFuture = accountManager.addAccount(accountType, null, null, null, AuthenticatedActivity.this, null, null);
- Bundle result;
- try {
- result = resultFuture.getResult();
- } catch (OperationCanceledException e) {
- e.printStackTrace();
- return null;
- } catch (AuthenticatorException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- return null;
- } catch (IOException e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- return null;
- }
- if(result.containsKey(AccountManager.KEY_ACCOUNT_NAME)) {
- return result.getString(AccountManager.KEY_ACCOUNT_NAME);
- } else {
- return null;
- }
-
- }
- }
-
- protected void requestAuthToken() {
- if(authCookie != null) {
- onAuthCookieAcquired(authCookie);
- return;
- }
- AccountManager accountManager = AccountManager.get(this);
- Account curAccount = app.getCurrentAccount();
- if(curAccount == null) {
- AddAccountTask addAccountTask = new AddAccountTask(accountManager);
- // This AsyncTask blocks until the Login Activity returns
- // And since in Android 4.x+ only one background thread runs all AsyncTasks
- // And since LoginActivity can't return until it's own AsyncTask (that does the login)
- // returns, we have a deadlock!
- // Fixed by explicitly asking this to be executed in parallel
- // See: https://groups.google.com/forum/?fromgroups=#!topic/android-developers/8M0RTFfO7-M
- addAccountTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- } else {
- GetAuthCookieTask task = new GetAuthCookieTask(curAccount, accountManager);
- task.execute();
- }
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- app = (CommonsApplication)this.getApplicationContext();
- if(savedInstanceState != null) {
- authCookie = savedInstanceState.getString("authCookie");
- }
- }
-
- @Override
- protected void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putString("authCookie", authCookie);
- }
-
- protected abstract void onAuthCookieAcquired(String authCookie);
- protected abstract void onAuthFailure();
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
deleted file mode 100644
index c66c82910..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
+++ /dev/null
@@ -1,265 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorActivity;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.app.Activity;
-import android.app.ProgressDialog;
-import android.content.ContentResolver;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.support.v4.app.NavUtils;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.inputmethod.EditorInfo;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import java.io.IOException;
-import java.util.Locale;
-
-import fr.free.nrw.commons.CommonsApplication;
-import fr.free.nrw.commons.EventLog;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.WelcomeActivity;
-import fr.free.nrw.commons.contributions.ContributionsActivity;
-import fr.free.nrw.commons.contributions.ContributionsContentProvider;
-import fr.free.nrw.commons.modifications.ModificationsContentProvider;
-
-
-public class LoginActivity extends AccountAuthenticatorActivity {
-
- public static final String PARAM_USERNAME = "fr.free.nrw.commons.login.username";
-
- private CommonsApplication app;
-
- private SharedPreferences prefs = null;
-
- Button loginButton;
- Button signupButton;
- EditText usernameEdit;
- EditText passwordEdit;
- ProgressDialog dialog;
-
- private class LoginTask extends AsyncTask {
-
- Activity context;
- String username;
- String password;
-
- @Override
- protected void onPostExecute(String result) {
- super.onPostExecute(result);
- Log.d("Commons", "Login done!");
-
- EventLog.schema(CommonsApplication.EVENT_LOGIN_ATTEMPT)
- .param("username", username)
- .param("result", result)
- .log();
-
- if (result.equals("Success")) {
- if (dialog != null && dialog.isShowing()) {
- dialog.dismiss();
- }
- Toast successToast = Toast.makeText(context, R.string.login_success, Toast.LENGTH_SHORT);
- successToast.show();
- Account account = new Account(username, WikiAccountAuthenticator.COMMONS_ACCOUNT_TYPE);
- boolean accountCreated = AccountManager.get(context).addAccountExplicitly(account, password, null);
-
- Bundle extras = context.getIntent().getExtras();
-
- if (extras != null) {
- Log.d("LoginActivity", "Bundle of extras: " + extras.toString());
- if (accountCreated) { // Pass the new account back to the account manager
- AccountAuthenticatorResponse response = extras.getParcelable(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE);
- Bundle authResult = new Bundle();
- authResult.putString(AccountManager.KEY_ACCOUNT_NAME, username);
- authResult.putString(AccountManager.KEY_ACCOUNT_TYPE, WikiAccountAuthenticator.COMMONS_ACCOUNT_TYPE);
-
- if (response != null) {
- response.onResult(authResult);
- }
- }
- }
- // FIXME: If the user turns it off, it shouldn't be auto turned back on
- ContentResolver.setSyncAutomatically(account, ContributionsContentProvider.AUTHORITY, true); // Enable sync by default!
- ContentResolver.setSyncAutomatically(account, ModificationsContentProvider.AUTHORITY, true); // Enable sync by default!
-
- Intent intent = new Intent(context, ContributionsActivity.class);
- startActivity(intent);
- finish();
-
- } else {
- int response;
- if(result.equals("NetworkFailure")) {
- response = R.string.login_failed_network;
- } else if(result.equals("NotExists") || result.equals("Illegal") || result.equals("NotExists")) {
- response = R.string.login_failed_username;
- passwordEdit.setText("");
- } else if(result.equals("EmptyPass") || result.equals("WrongPass") || result.equals("WrongPluginPass")) {
- response = R.string.login_failed_password;
- passwordEdit.setText("");
- } else if(result.equals("Throttled")) {
- response = R.string.login_failed_throttled;
- } else if(result.equals("Blocked")) {
- response = R.string.login_failed_blocked;
- } else {
- // Should never really happen
- Log.d("Commons", "Login failed with reason: " + result);
- response = R.string.login_failed_generic;
- }
- Toast.makeText(getApplicationContext(), response, Toast.LENGTH_LONG).show();
- dialog.cancel();
- }
- }
-
- @Override
- protected void onPreExecute() {
- super.onPreExecute();
- dialog = new ProgressDialog(context);
- dialog.setIndeterminate(true);
- dialog.setTitle(getString(R.string.logging_in_title));
- dialog.setMessage(getString(R.string.logging_in_message));
- dialog.setCanceledOnTouchOutside(false);
- dialog.show();
- }
-
- LoginTask(Activity context) {
- this.context = context;
- }
-
- @Override
- protected String doInBackground(String... params) {
- username = params[0];
- password = params[1];
- try {
- return app.getApi().login(username, password);
- } catch (IOException e) {
- // Do something better!
- return "NetworkFailure";
- }
- }
- }
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- app = (CommonsApplication) this.getApplicationContext();
- setContentView(R.layout.activity_login);
- loginButton = (Button) findViewById(R.id.loginButton);
- signupButton = (Button) findViewById(R.id.signupButton);
- usernameEdit = (EditText) findViewById(R.id.loginUsername);
- passwordEdit = (EditText) findViewById(R.id.loginPassword);
- final LoginActivity that = this;
-
- prefs = getSharedPreferences("fr.free.nrw.commons", MODE_PRIVATE);
-
- TextWatcher loginEnabler = new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) { }
-
- @Override
- public void onTextChanged(CharSequence charSequence, int start, int count, int after) { }
-
- @Override
- public void afterTextChanged(Editable editable) {
- if(usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0) {
- loginButton.setEnabled(true);
- } else {
- loginButton.setEnabled(false);
- }
- }
- };
-
- usernameEdit.addTextChangedListener(loginEnabler);
- passwordEdit.addTextChangedListener(loginEnabler);
- passwordEdit.setOnEditorActionListener(new TextView.OnEditorActionListener() {
- @Override
- public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
- if (loginButton.isEnabled()) {
- if (actionId == EditorInfo.IME_ACTION_DONE) {
- performLogin();
- return true;
- } else if ((keyEvent != null) && keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
- performLogin();
- return true;
- }
- }
- return false;
- }
- });
-
- loginButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- that.performLogin();
- }
- });
-
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- if (prefs.getBoolean("firstrun", true)) {
- // Do first run stuff here then set 'firstrun' as false
- Intent welcomeIntent = new Intent(this, WelcomeActivity.class);
- startActivity(welcomeIntent);
- prefs.edit().putBoolean("firstrun", false).apply();
- }
- }
-
-
- @Override
- protected void onDestroy() {
- try {
- // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
- if (dialog != null && dialog.isShowing()) {
- dialog.dismiss();
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- super.onDestroy();
- }
-
- private void performLogin() {
- String username = usernameEdit.getText().toString();
- // Because Mediawiki is upercase-first-char-then-case-sensitive :)
- String canonicalUsername = Utils.capitalize(username.substring(0,1)) + username.substring(1);
-
- String password = passwordEdit.getText().toString();
-
- Log.d("Commons", "Login to start!");
- LoginTask task = new LoginTask(this);
- task.execute(canonicalUsername, password);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case android.R.id.home:
- NavUtils.navigateUpFromSameTask(this);
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- //Called when Sign Up button is clicked
- public void signUp(View view) {
- Intent intent = new Intent(this, SignupActivity.class);
- startActivity(intent);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
new file mode 100644
index 000000000..0c9901b56
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
@@ -0,0 +1,489 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AccountAuthenticatorActivity
+import android.app.ProgressDialog
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.app.NavUtils
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.ActivityLoginBinding
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.AbstractTextWatcher
+import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+import fr.free.nrw.commons.utils.SystemThemeUtils
+import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
+import fr.free.nrw.commons.utils.handleKeyboardInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import io.reactivex.disposables.CompositeDisposable
+import timber.log.Timber
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Named
+
+class LoginActivity : AccountAuthenticatorActivity() {
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ @field:Named("default_preferences")
+ lateinit var applicationKvStore: JsonKvStore
+
+ @Inject
+ lateinit var loginClient: LoginClient
+
+ @Inject
+ lateinit var systemThemeUtils: SystemThemeUtils
+
+ private var binding: ActivityLoginBinding? = null
+ private var progressDialog: ProgressDialog? = null
+ private val textWatcher = AbstractTextWatcher(::onTextChanged)
+ private val compositeDisposable = CompositeDisposable()
+ private val delegate: AppCompatDelegate by lazy {
+ AppCompatDelegate.create(this, null)
+ }
+ private var lastLoginResult: LoginResult? = null
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ ApplicationlessInjection
+ .getInstance(this.applicationContext)
+ .commonsApplicationComponent
+ .inject(this)
+
+ val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
+ setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
+ delegate.installViewFactory()
+ delegate.onCreate(savedInstanceState)
+
+ WindowCompat.getInsetsController(window, window.decorView)
+ .isAppearanceLightStatusBars = !isDarkTheme
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ applyEdgeToEdgeAllInsets(binding!!.root)
+ binding!!.root.handleKeyboardInsets()
+ with(binding!!) {
+ setContentView(root)
+
+ loginUsername.addTextChangedListener(textWatcher)
+ loginPassword.addTextChangedListener(textWatcher)
+ loginTwoFactor.addTextChangedListener(textWatcher)
+
+ skipLogin.setOnClickListener { skipLogin() }
+ forgotPassword.setOnClickListener { forgotPassword() }
+ aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
+ signUpButton.setOnClickListener { signUp() }
+ loginButton.setOnClickListener { performLogin() }
+ loginPassword.setOnEditorActionListener { textView, actionId, keyEvent ->
+ if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+ if (actionId == EditorInfo.IME_ACTION_NEXT && lastLoginResult != null) {
+ askUserForTwoFactorAuthWithKeyboard()
+ true
+ } else {
+ performLogin()
+ true
+ }
+ } else {
+ false
+ }
+ }
+
+ loginPassword.onFocusChangeListener =
+ View.OnFocusChangeListener(::onPasswordFocusChanged)
+
+ if (isBetaFlavour) {
+ loginCredentials.text = getString(R.string.login_credential)
+ } else {
+ loginCredentials.visibility = View.GONE
+ }
+
+ intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
+ showMessage(it, R.color.secondaryDarkColor)
+ }
+
+ intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
+ loginUsername.setText(it)
+ }
+ }
+ }
+
+ @VisibleForTesting
+ fun askUserForTwoFactorAuthWithKeyboard() {
+ if (binding == null) {
+ Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuthWithKeyboard")
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding!!.root)
+ }
+ progressDialog!!.dismiss()
+ if (binding != null) {
+ with(binding!!) {
+ twoFactorContainer.visibility = View.VISIBLE
+ twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+ loginTwoFactor.visibility = View.VISIBLE
+ loginTwoFactor.requestFocus()
+
+ val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.showSoftInput(loginTwoFactor, InputMethodManager.SHOW_IMPLICIT)
+
+ loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+ performLogin()
+ true
+ } else {
+ false
+ }
+ }
+ }
+ } else {
+ Timber.e("Binding is null in askUserForTwoFactorAuthWithKeyboard after reinitialization attempt")
+ }
+ showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+ }
+ override fun onPostCreate(savedInstanceState: Bundle?) {
+ super.onPostCreate(savedInstanceState)
+ delegate.onPostCreate(savedInstanceState)
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
+ applicationKvStore.putBoolean("login_skipped", false)
+ startMainActivity()
+ }
+
+ if (applicationKvStore.getBoolean("login_skipped", false)) {
+ performSkipLogin()
+ }
+ }
+
+ override fun onDestroy() {
+ compositeDisposable.clear()
+ try {
+ // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
+ if (progressDialog?.isShowing == true) {
+ progressDialog!!.dismiss()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ with(binding!!) {
+ loginUsername.removeTextChangedListener(textWatcher)
+ loginPassword.removeTextChangedListener(textWatcher)
+ loginTwoFactor.removeTextChangedListener(textWatcher)
+ }
+ delegate.onDestroy()
+ loginClient.cancel()
+ binding = null
+ super.onDestroy()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ delegate.onStart()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ delegate.onStop()
+ }
+
+ override fun onPostResume() {
+ super.onPostResume()
+ delegate.onPostResume()
+ }
+
+ override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
+ delegate.setContentView(view, params)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ NavUtils.navigateUpFromSameTask(this)
+ return true
+ }
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ // if progressDialog is visible during the configuration change then store state as true else false so that
+ // we maintain visibility of progressDialog after configuration change
+ if (progressDialog != null && progressDialog!!.isShowing) {
+ outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
+ } else {
+ outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
+ }
+ outState.putString(
+ SAVE_ERROR_MESSAGE,
+ binding!!.errorMessage.text.toString()
+ ) //Save the errorMessage
+ outState.putString(
+ SAVE_USERNAME,
+ binding!!.loginUsername.text.toString()
+ ) // Save the username
+ outState.putString(
+ SAVE_PASSWORD,
+ binding!!.loginPassword.text.toString()
+ ) // Save the password
+ }
+
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+ binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
+ binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
+ if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
+ performLogin()
+ }
+ val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
+ if (sessionManager.isUserLoggedIn) {
+ showMessage(R.string.login_success, R.color.primaryDarkColor)
+ } else {
+ showMessage(errorMessage, R.color.secondaryDarkColor)
+ }
+ }
+
+ /**
+ * Hides the keyboard if the user's focus is not on the password (hasFocus is false).
+ * @param view The keyboard
+ * @param hasFocus Set to true if the keyboard has focus
+ */
+ private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
+ if (!hasFocus) {
+ hideKeyboard(view)
+ }
+ }
+
+ private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
+ if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+ performLogin()
+ true
+ } else false
+
+ private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
+ actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
+
+ private fun skipLogin() {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.skip_login_title)
+ .setMessage(R.string.skip_login_message)
+ .setCancelable(false)
+ .setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
+ dialog.cancel()
+ performSkipLogin()
+ }
+ .setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
+ dialog.cancel()
+ }
+ .show()
+ }
+
+ private fun forgotPassword() =
+ handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
+
+ private fun onPrivacyPolicyClicked() =
+ handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
+
+ private fun signUp() =
+ startActivity(Intent(this, SignupActivity::class.java))
+
+ @VisibleForTesting
+ fun performLogin() {
+ Timber.d("Login to start!")
+ val username = binding!!.loginUsername.text.toString()
+ val password = binding!!.loginPassword.text.toString()
+ val twoFactorCode = binding!!.loginTwoFactor.text.toString()
+
+ showLoggingProgressBar()
+ loginClient.doLogin(username,
+ password,
+ lastLoginResult,
+ twoFactorCode,
+ Locale.getDefault().language,
+ object : LoginCallback {
+ override fun success(loginResult: LoginResult) = runOnUiThread {
+ Timber.d("Login Success")
+ progressDialog!!.dismiss()
+ onLoginSuccess(loginResult)
+ }
+
+ override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+ Timber.d("Requesting 2FA prompt")
+ progressDialog!!.dismiss()
+ lastLoginResult = loginResult
+ askUserForTwoFactorAuthWithKeyboard()
+ }
+
+ override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+ Timber.d("Requesting email auth prompt")
+ progressDialog!!.dismiss()
+ lastLoginResult = loginResult
+ askUserForTwoFactorAuthWithKeyboard()
+ }
+
+ override fun passwordResetPrompt(token: String?) = runOnUiThread {
+ Timber.d("Showing password reset prompt")
+ progressDialog!!.dismiss()
+ showPasswordResetPrompt()
+ }
+
+ override fun error(caught: Throwable) = runOnUiThread {
+ Timber.e(caught)
+ progressDialog!!.dismiss()
+ showMessageAndCancelDialog(caught.localizedMessage ?: "")
+ }
+ }
+ )
+ }
+
+ private fun showPasswordResetPrompt() =
+ showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
+
+ /**
+ * This function is called when user skips the login.
+ * It redirects the user to Explore Activity.
+ */
+ private fun performSkipLogin() {
+ applicationKvStore.putBoolean("login_skipped", true)
+ MainActivity.startYourself(this)
+ finish()
+ }
+
+ private fun showLoggingProgressBar() {
+ progressDialog = ProgressDialog(this).apply {
+ isIndeterminate = true
+ setTitle(getString(R.string.logging_in_title))
+ setMessage(getString(R.string.logging_in_message))
+ setCancelable(false)
+ }
+ progressDialog!!.show()
+ }
+
+ private fun onLoginSuccess(loginResult: LoginResult) {
+ compositeDisposable.clear()
+ sessionManager.setUserLoggedIn(true)
+ sessionManager.updateAccount(loginResult)
+ progressDialog!!.dismiss()
+ showSuccessAndDismissDialog()
+ startMainActivity()
+ }
+
+ override fun getMenuInflater(): MenuInflater =
+ delegate.menuInflater
+
+ @VisibleForTesting
+ fun askUserForTwoFactorAuth() {
+ if (binding == null) {
+ Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuth")
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding!!.root)
+ }
+ progressDialog!!.dismiss()
+ if (binding != null) {
+ with(binding!!) {
+ twoFactorContainer.visibility = View.VISIBLE
+ twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+ loginTwoFactor.visibility = View.VISIBLE
+ loginTwoFactor.requestFocus()
+
+ loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+ performLogin()
+ true
+ } else {
+ false
+ }
+ }
+ }
+ } else {
+ Timber.e("Binding is null in askUserForTwoFactorAuth after reinitialization attempt")
+ }
+ val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
+ showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(@StringRes resId: Int) {
+ showMessage(resId, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(error: String) {
+ showMessage(error, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showSuccessAndDismissDialog() {
+ showMessage(R.string.login_success, R.color.primaryDarkColor)
+ progressDialog!!.dismiss()
+ }
+
+ @VisibleForTesting
+ fun startMainActivity() {
+ startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ finish()
+ }
+
+ private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = getString(resId)
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = message
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun onTextChanged(text: String) {
+ val enabled =
+ binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
+ (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
+ binding!!.loginButton.isEnabled = enabled
+ }
+
+ companion object {
+ fun startYourself(context: Context) =
+ context.startActivity(Intent(context, LoginActivity::class.java))
+
+ const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
+ const val SAVE_ERROR_MESSAGE: String = "errorMessage"
+ const val SAVE_USERNAME: String = "username"
+ const val SAVE_PASSWORD: String = "password"
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
new file mode 100644
index 000000000..c9eb7d2f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
@@ -0,0 +1,95 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import android.os.Build
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.reactivex.Completable
+import io.reactivex.Observable
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Manage the current logged in user session.
+ */
+@Singleton
+class SessionManager @Inject constructor(
+ private val context: Context,
+ @param:Named("default_preferences") private val defaultKvStore: JsonKvStore
+) {
+ private val accountManager: AccountManager get() = AccountManager.get(context)
+
+ private var _currentAccount: Account? = null // Unlike a savings account... ;-)
+ val currentAccount: Account? get() {
+ if (_currentAccount == null) {
+ val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
+ if (allAccounts.isNotEmpty()) {
+ _currentAccount = allAccounts[0]
+ }
+ }
+ return _currentAccount
+ }
+
+ val userName: String?
+ get() = currentAccount?.name
+
+ var password: String?
+ get() = currentAccount?.let { accountManager.getPassword(it) }
+ private set(value) {
+ currentAccount?.let { accountManager.setPassword(it, value) }
+ }
+
+ val isUserLoggedIn: Boolean
+ get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
+
+ fun updateAccount(result: LoginResult) {
+ if (createAccount(result.userName!!, result.password!!)) {
+ password = result.password
+ }
+ }
+
+ fun doesAccountExist(): Boolean =
+ currentAccount != null
+
+ fun setUserLoggedIn(isLoggedIn: Boolean) =
+ defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
+
+ fun forceLogin(context: Context?) =
+ context?.let { LoginActivity.startYourself(it) }
+
+ fun getPreference(key: String): Boolean =
+ defaultKvStore.getBoolean(key)
+
+ fun logout(): Completable = Completable.fromObservable(
+ Observable.empty()
+ .doOnComplete {
+ removeAccount()
+ _currentAccount = null
+ }
+ )
+
+ private fun createAccount(userName: String, password: String): Boolean {
+ var account = currentAccount
+ if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
+ removeAccount()
+ account = Account(userName, ACCOUNT_TYPE)
+ return accountManager.addAccountExplicitly(account, password, null)
+ }
+ return true
+ }
+
+ private fun removeAccount() {
+ currentAccount?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+ accountManager.removeAccountExplicitly(it)
+ } else {
+ accountManager.removeAccount(it, null, null)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
deleted file mode 100644
index cd65826b0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.util.Log;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.Toast;
-
-import fr.free.nrw.commons.theme.BaseActivity;
-
-public class SignupActivity extends BaseActivity {
-
- private WebView webView;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- Log.d("SignupActivity", "Signup Activity started");
-
- getSupportActionBar().hide();
-
- webView = new WebView(this);
- setContentView(webView);
-
- webView.setWebViewClient(new MyWebViewClient());
- WebSettings webSettings = webView.getSettings();
- //Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can trust Wikimedia's site... right?
- webSettings.setJavaScriptEnabled(true);
-
- webView.loadUrl("https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes");
- }
-
- private class MyWebViewClient extends WebViewClient {
- @Override
- public boolean shouldOverrideUrlLoading(WebView view, String url) {
- if (url.equals("https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes")) {
- //Signup success, so clear cookies, notify user, and load LoginActivity again
- Log.d("SignupActivity", "Overriding URL" + url);
-
- Toast toast = Toast.makeText(getApplicationContext(), "Account created!", Toast.LENGTH_LONG);
- toast.show();
-
- Intent intent = new Intent(getApplicationContext(), LoginActivity.class);
- startActivity(intent);
- return true;
- } else {
- //If user clicks any other links in the webview
- Log.d("SignupActivity", "Not overriding URL, URL is: " + url);
- return false;
- }
- }
- }
-
- @Override
- public void onBackPressed() {
- if (webView.canGoBack()) {
- webView.goBack();
- } else {
- super.onBackPressed();
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
new file mode 100644
index 000000000..22f557bcd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
@@ -0,0 +1,77 @@
+package fr.free.nrw.commons.auth
+
+import android.annotation.SuppressLint
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Bundle
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import timber.log.Timber
+
+class SignupActivity : BaseActivity() {
+ private var webView: WebView? = null
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Timber.d("Signup Activity started")
+
+ webView = WebView(this)
+ applyEdgeToEdgeAllInsets(webView!!)
+ with(webView!!) {
+ setContentView(this)
+ webViewClient = MyWebViewClient()
+ // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
+ // trust Wikimedia's site... right?
+ settings.javaScriptEnabled = true
+ loadUrl(BuildConfig.SIGNUP_LANDING_URL)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (webView!!.canGoBack()) {
+ webView!!.goBack()
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+ /**
+ * Known bug in androidx.appcompat library version 1.1.0 being tracked here
+ * https://issuetracker.google.com/issues/141132133
+ * App tries to put light/dark theme to webview and crashes in the process
+ * This code tries to prevent applying the theme when sdk is between api 21 to 25
+ */
+ override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
+ if (Build.VERSION.SDK_INT <= 25 &&
+ (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
+ ) return
+ super.applyOverrideConfiguration(overrideConfiguration)
+ }
+
+ private inner class MyWebViewClient : WebViewClient() {
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
+ if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
+ //Signup success, so clear cookies, notify user, and load LoginActivity again
+ Timber.d("Overriding URL %s", url)
+
+ Toast.makeText(
+ this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
+ ).show()
+
+ // terminate on task completion.
+ finish()
+ true
+ } else {
+ //If user clicks any other links in the webview
+ Timber.d("Not overriding URL, URL is: %s", url)
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
deleted file mode 100644
index 9194fa316..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
+++ /dev/null
@@ -1,107 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.accounts.NetworkErrorException;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import org.mediawiki.api.MWApi;
-
-import java.io.IOException;
-
-import fr.free.nrw.commons.CommonsApplication;
-
-public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
-
- public static final String COMMONS_ACCOUNT_TYPE = "fr.free.nrw.commons";
- private Context context;
- public WikiAccountAuthenticator(Context context) {
- super(context);
- this.context = context;
- }
-
- @Override
- public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
- final Intent intent = new Intent(context, LoginActivity.class);
- intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
- final Bundle bundle = new Bundle();
- bundle.putParcelable(AccountManager.KEY_INTENT, intent);
- return bundle;
- }
-
- @Override
- public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
- return null;
- }
-
- @Override
- public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
- return null;
- }
-
- private String getAuthCookie(String username, String password) throws IOException {
- MWApi api = CommonsApplication.createMWApi();
- String result = api.login(username, password);
- if(result.equals("Success")) {
- return api.getAuthCookie();
- } else {
- return null;
- }
- }
- @Override
- public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
- // Extract the username and password from the Account Manager, and ask
- // the server for an appropriate AuthToken.
- final AccountManager am = AccountManager.get(context);
- final String password = am.getPassword(account);
- if (password != null) {
- String authCookie;
- try {
- authCookie = getAuthCookie(account.name, password);
- } catch (IOException e) {
- // Network error!
- e.printStackTrace();
- throw new NetworkErrorException(e);
- }
- if (authCookie != null) {
- final Bundle result = new Bundle();
- result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
- result.putString(AccountManager.KEY_ACCOUNT_TYPE, COMMONS_ACCOUNT_TYPE);
- result.putString(AccountManager.KEY_AUTHTOKEN, authCookie);
- return result;
- }
- }
-
- // If we get here, then we couldn't access the user's password - so we
- // need to re-prompt them for their credentials. We do that by creating
- // an intent to display our AuthenticatorActivity panel.
- final Intent intent = new Intent(context, LoginActivity.class);
- intent.putExtra(LoginActivity.PARAM_USERNAME, account.name);
- intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
- final Bundle bundle = new Bundle();
- bundle.putParcelable(AccountManager.KEY_INTENT, intent);
- return bundle;
- }
-
- @Override
- public String getAuthTokenLabel(String authTokenType) {
- return null;
- }
-
- @Override
- public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
- final Bundle result = new Bundle();
- result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
- return result;
- }
-
- @Override
- public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
- return null;
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
new file mode 100644
index 000000000..367989f14
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
@@ -0,0 +1,108 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.accounts.NetworkErrorException
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import fr.free.nrw.commons.BuildConfig
+
+private val SYNC_AUTHORITIES = arrayOf(
+ BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
+)
+
+/**
+ * Handles WikiMedia commons account Authentication
+ */
+class WikiAccountAuthenticator(
+ private val context: Context
+) : AbstractAccountAuthenticator(context) {
+ /**
+ * Provides Bundle with edited Account Properties
+ */
+ override fun editProperties(
+ response: AccountAuthenticatorResponse,
+ accountType: String
+ ) = bundleOf("test" to "editProperties")
+
+ // account type not supported returns bundle without loginActivity Intent, it just contains "test" key
+ @Throws(NetworkErrorException::class)
+ override fun addAccount(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle?
+ ) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
+ addAccount(response)
+ } else {
+ bundleOf("test" to "addAccount")
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse, account: Account, options: Bundle?
+ ) = bundleOf("test" to "confirmCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String,
+ options: Bundle?
+ ) = bundleOf("test" to "getAuthToken")
+
+ override fun getAuthTokenLabel(authTokenType: String) =
+ if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
+
+ @Throws(NetworkErrorException::class)
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String?,
+ options: Bundle?
+ ) = bundleOf("test" to "updateCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse,
+ account: Account, features: Array
+ ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
+
+ /**
+ * Provides a bundle containing a Parcel
+ * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
+ */
+ private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
+ val intent = Intent(context, LoginActivity::class.java)
+ .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+ return bundleOf(AccountManager.KEY_INTENT to intent)
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun getAccountRemovalAllowed(
+ response: AccountAuthenticatorResponse?,
+ account: Account?
+ ): Bundle {
+ val result = super.getAccountRemovalAllowed(response, account)
+
+ if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
+ && !result.containsKey(AccountManager.KEY_INTENT)
+ ) {
+ val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
+
+ if (allowed) {
+ for (auth in SYNC_AUTHORITIES) {
+ ContentResolver.cancelSync(account, auth)
+ }
+ }
+ }
+
+ return result
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
deleted file mode 100644
index c5201f95a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AccountManager;
-import android.app.Service;
-import android.content.Intent;
-import android.os.IBinder;
-
-public class WikiAccountAuthenticatorService extends Service{
-
- private static WikiAccountAuthenticator wikiAccountAuthenticator = null;
-
- @Override
- public IBinder onBind(Intent intent) {
- if (!intent.getAction().equals(AccountManager.ACTION_AUTHENTICATOR_INTENT)) {
- return null;
- }
-
- if(wikiAccountAuthenticator == null) {
- wikiAccountAuthenticator = new WikiAccountAuthenticator(this);
- }
- return wikiAccountAuthenticator.getIBinder();
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
new file mode 100644
index 000000000..852536a48
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
@@ -0,0 +1,22 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.content.Intent
+import android.os.IBinder
+import fr.free.nrw.commons.di.CommonsDaggerService
+
+/**
+ * Handles the Auth service of the App, see AndroidManifests for details
+ * (Uses Dagger 2 as injector)
+ */
+class WikiAccountAuthenticatorService : CommonsDaggerService() {
+ private var authenticator: AbstractAccountAuthenticator? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ authenticator = WikiAccountAuthenticator(this)
+ }
+
+ override fun onBind(intent: Intent): IBinder? =
+ authenticator?.iBinder
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
new file mode 100644
index 000000000..6353e54ac
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
@@ -0,0 +1,217 @@
+package fr.free.nrw.commons.auth.csrf
+
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginFailedException
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.Response
+import timber.log.Timber
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors.newSingleThreadExecutor
+
+class CsrfTokenClient(
+ private val sessionManager: SessionManager,
+ private val csrfTokenInterface: CsrfTokenInterface,
+ private val loginClient: LoginClient,
+ private val logoutClient: LogoutClient,
+) {
+ private var retries = 0
+ private var csrfTokenCall: Call? = null
+
+ @Throws(Throwable::class)
+ fun getTokenBlocking(): String {
+ var token = ""
+ val userName = sessionManager.userName ?: ""
+ val password = sessionManager.password ?: ""
+
+ for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) {
+ try {
+ if (retry > 0) {
+ // Log in explicitly
+ loginClient.loginBlocking(userName, password)
+ }
+
+ // Get CSRFToken response off the main thread.
+ val response =
+ newSingleThreadExecutor()
+ .submit(
+ Callable {
+ csrfTokenInterface.getCsrfTokenCall().execute()
+ },
+ ).get()
+
+ if (response
+ .body()
+ ?.query()
+ ?.csrfToken()
+ .isNullOrEmpty()
+ ) {
+ continue
+ }
+
+ token = response.body()!!.query()!!.csrfToken()!!
+ if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ break
+ } catch (e: LoginFailedException) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ } catch (t: Throwable) {
+ Timber.w(t)
+ }
+ }
+
+ if (token.isEmpty() || token == ANON_TOKEN) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ return token
+ }
+
+ @VisibleForTesting
+ fun request(
+ service: CsrfTokenInterface,
+ cb: Callback,
+ ): Call =
+ requestToken(
+ service,
+ object : Callback {
+ override fun success(token: String?) {
+ if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+ retryWithLogin(cb) {
+ InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ } else {
+ cb.success(token)
+ }
+ }
+
+ override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught }
+
+ override fun twoFactorPrompt() = cb.twoFactorPrompt()
+
+ override fun emailAuthPrompt() = cb.emailAuthPrompt()
+ },
+ )
+
+ @VisibleForTesting
+ fun requestToken(
+ service: CsrfTokenInterface,
+ cb: Callback,
+ ): Call {
+ val call = service.getCsrfTokenCall()
+ call.enqueue(
+ object : retrofit2.Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.success(response.body()!!.query()!!.csrfToken())
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.failure(t)
+ }
+ },
+ )
+ return call
+ }
+
+ private fun retryWithLogin(
+ callback: Callback,
+ caught: () -> Throwable?,
+ ) {
+ val userName = sessionManager.userName
+ val password = sessionManager.password
+ if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) {
+ retries++
+ logoutClient.logout()
+ login(userName, password, callback) {
+ Timber.i("retrying...")
+ cancel()
+ csrfTokenCall = request(csrfTokenInterface, callback)
+ }
+ } else {
+ callback.failure(caught())
+ }
+ }
+
+ private fun login(
+ username: String,
+ password: String,
+ callback: Callback,
+ retryCallback: () -> Unit,
+ ) = loginClient.request(
+ username,
+ password,
+ object : LoginCallback {
+ override fun success(loginResult: LoginResult) {
+ if (loginResult.pass) {
+ sessionManager.updateAccount(loginResult)
+ retryCallback()
+ } else {
+ callback.failure(LoginFailedException(loginResult.message))
+ }
+ }
+
+ override fun twoFactorPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ ) = callback.twoFactorPrompt()
+
+ override fun emailAuthPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ ) = callback.emailAuthPrompt()
+
+ // Should not happen here, but call the callback just in case.
+ override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password."))
+
+ override fun error(caught: Throwable) = callback.failure(caught)
+ },
+ )
+
+ private fun cancel() {
+ loginClient.cancel()
+ if (csrfTokenCall != null) {
+ csrfTokenCall!!.cancel()
+ csrfTokenCall = null
+ }
+ }
+
+ interface Callback {
+ fun success(token: String?)
+
+ fun failure(caught: Throwable?)
+
+ fun twoFactorPrompt()
+
+ fun emailAuthPrompt()
+ }
+
+ companion object {
+ private const val ANON_TOKEN = "+\\"
+ private const val MAX_RETRIES = 1
+ private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2
+ const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure."
+ const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token."
+ }
+}
+
+class InvalidLoginTokenException(
+ message: String,
+) : Exception(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
new file mode 100644
index 000000000..949f2dddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
@@ -0,0 +1,13 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Headers
+
+interface CsrfTokenInterface {
+ @Headers("Cache-Control: no-cache")
+ @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf")
+ fun getCsrfTokenCall(): Call
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
new file mode 100644
index 000000000..84481c918
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
@@ -0,0 +1,12 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
+import javax.inject.Inject
+
+class LogoutClient
+ @Inject
+ constructor(
+ private val store: CommonsCookieStorage,
+ ) {
+ fun logout() = store.clear()
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
new file mode 100644
index 000000000..8aa3d17a0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.auth.login
+
+interface LoginCallback {
+ fun success(loginResult: LoginResult)
+
+ fun twoFactorPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ )
+
+ fun emailAuthPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ )
+
+ fun passwordResetPrompt(token: String?)
+
+ fun error(caught: Throwable)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
new file mode 100644
index 000000000..a653b8b55
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
@@ -0,0 +1,276 @@
+package fr.free.nrw.commons.auth.login
+
+import android.text.TextUtils
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import timber.log.Timber
+import java.io.IOException
+
+/**
+ * Responsible for making login related requests to the server.
+ */
+class LoginClient(
+ private val loginInterface: LoginInterface,
+) {
+ private var tokenCall: Call? = null
+ private var loginCall: Call? = null
+
+ /**
+ * userLanguage
+ * It holds the value of the user's device language code.
+ * For example, if user's device language is English it will hold En
+ * The value will be fetched when the user clicks Login Button in the LoginActivity
+ */
+ private var userLanguage = ""
+
+ private fun getLoginToken() = loginInterface.getLoginToken()
+
+ fun request(
+ userName: String,
+ password: String,
+ cb: LoginCallback,
+ ) {
+ cancel()
+
+ tokenCall = getLoginToken()
+ tokenCall!!.enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ login(
+ userName,
+ password,
+ null,
+ null,
+ null,
+ response.body()!!.query()!!.loginToken(),
+ userLanguage,
+ cb,
+ )
+ }
+
+ override fun onFailure(
+ call: Call,
+ caught: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.error(caught)
+ }
+ },
+ )
+ }
+
+ fun login(
+ userName: String,
+ password: String,
+ retypedPassword: String?,
+ twoFactorCode: String?,
+ emailAuthCode: String?,
+ loginToken: String?,
+ userLanguage: String,
+ cb: LoginCallback,
+ ) {
+ this.userLanguage = userLanguage
+
+ loginCall =
+ if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ retypedPassword,
+ twoFactorCode,
+ emailAuthCode,
+ loginToken,
+ userLanguage,
+ true,
+ )
+ }
+
+ loginCall!!.enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ val loginResult = response.body()?.toLoginResult(password)
+ if (loginResult != null) {
+ if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
+ // The server could do some transformations on user names, e.g. on some
+ // wikis is uppercases the first letter.
+ getExtendedInfo(loginResult.userName, loginResult, cb)
+ } else if ("UI" == loginResult.status) {
+ when (loginResult) {
+ is OAuthResult ->
+ cb.twoFactorPrompt(
+ loginResult,
+ LoginFailedException(loginResult.message),
+ loginToken,
+ )
+
+ is EmailAuthResult ->
+ cb.emailAuthPrompt(
+ loginResult,
+ LoginFailedException(loginResult.message),
+ loginToken
+ )
+
+ is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
+
+ is LoginResult.Result ->
+ cb.error(
+ LoginFailedException(loginResult.message),
+ )
+ }
+ } else {
+ cb.error(LoginFailedException(loginResult.message))
+ }
+ } else {
+ cb.error(IOException("Login failed. Unexpected response."))
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.error(t)
+ }
+ },
+ )
+ }
+
+ fun doLogin(
+ username: String,
+ password: String,
+ lastLoginResult: LoginResult?,
+ twoFactorCode: String,
+ userLanguage: String,
+ loginCallback: LoginCallback,
+ ) {
+ getLoginToken().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) = if (response.isSuccessful) {
+ val loginToken = response.body()?.query()?.loginToken()
+ loginToken?.let {
+ login(username, password, null,
+ if (lastLoginResult is OAuthResult) twoFactorCode else null,
+ if (lastLoginResult is EmailAuthResult) twoFactorCode else null,
+ it, userLanguage, loginCallback)
+ } ?: run {
+ loginCallback.error(IOException("Failed to retrieve login token"))
+ }
+ } else {
+ loginCallback.error(IOException("Failed to retrieve login token"))
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ loginCallback.error(t)
+ }
+ },
+ )
+ }
+
+ @Throws(Throwable::class)
+ fun loginBlocking(
+ userName: String,
+ password: String,
+ twoFactorCode: String? = null,
+ emailAuthCode: String? = null
+ ) {
+ val tokenResponse = getLoginToken().execute()
+ if (tokenResponse
+ .body()
+ ?.query()
+ ?.loginToken()
+ .isNullOrEmpty()
+ ) {
+ throw IOException("Unexpected response when getting login token.")
+ }
+
+ val loginToken = tokenResponse.body()?.query()?.loginToken()
+ val tempLoginCall =
+ if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ null,
+ twoFactorCode,
+ emailAuthCode,
+ loginToken,
+ userLanguage,
+ true,
+ )
+ }
+
+ val response = tempLoginCall.execute()
+ val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.")
+ val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.")
+
+ if ("UI" == loginResult.status) {
+ if (loginResult is OAuthResult || loginResult is EmailAuthResult) {
+ // TODO: Find a better way to boil up the warning about 2FA
+ throw LoginFailedException(loginResult.message)
+ }
+ throw LoginFailedException(loginResult.message)
+ }
+
+ if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
+ throw LoginFailedException(loginResult.message)
+ }
+ }
+
+ private fun getExtendedInfo(
+ userName: String,
+ loginResult: LoginResult,
+ cb: LoginCallback,
+ ) = loginInterface
+ .getUserInfo(userName)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe({ response: MwQueryResponse? ->
+ loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
+ loginResult.groups =
+ response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
+ cb.success(loginResult)
+ }, { caught: Throwable ->
+ Timber.e(caught, "Login succeeded but getting group information failed. ")
+ cb.error(caught)
+ })
+
+ fun cancel() {
+ tokenCall?.let {
+ it.cancel()
+ tokenCall = null
+ }
+
+ loginCall?.let {
+ it.cancel()
+ loginCall = null
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
new file mode 100644
index 000000000..fb5ad14c6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.auth.login
+
+class LoginFailedException(
+ message: String?,
+) : Throwable(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
new file mode 100644
index 000000000..39cbf7c9f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
@@ -0,0 +1,48 @@
+package fr.free.nrw.commons.auth.login
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Observable
+import retrofit2.Call
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+interface LoginInterface {
+ @Headers("Cache-Control: no-cache")
+ @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login")
+ fun getLoginToken(): Call
+
+ @Headers("Cache-Control: no-cache")
+ @FormUrlEncoded
+ @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+ fun postLogIn(
+ @Field("username") user: String?,
+ @Field("password") pass: String?,
+ @Field("logintoken") token: String?,
+ @Field("uselang") userLanguage: String?,
+ @Field("loginreturnurl") url: String?,
+ ): Call
+
+ @Headers("Cache-Control: no-cache")
+ @FormUrlEncoded
+ @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+ fun postLogIn(
+ @Field("username") user: String?,
+ @Field("password") pass: String?,
+ @Field("retype") retypedPass: String?,
+ @Field("OATHToken") twoFactorCode: String?,
+ @Field("token") emailAuthToken: String?,
+ @Field("logintoken") loginToken: String?,
+ @Field("uselang") userLanguage: String?,
+ @Field("logincontinue") loginContinue: Boolean,
+ ): Call
+
+ @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
+ fun getUserInfo(
+ @Query("ususers") userName: String,
+ ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
new file mode 100644
index 000000000..0fb035eea
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
@@ -0,0 +1,64 @@
+package fr.free.nrw.commons.auth.login
+
+import com.google.gson.annotations.SerializedName
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.auth.login.LoginResult.Result
+import fr.free.nrw.commons.wikidata.mwapi.MwServiceError
+
+class LoginResponse {
+ @SerializedName("error")
+ val error: MwServiceError? = null
+
+ @SerializedName("clientlogin")
+ private val clientLogin: ClientLogin? = null
+
+ fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
+}
+
+internal class ClientLogin {
+ private val status: String? = null
+ private val requests: List? = null
+ private val message: String? = null
+
+ @SerializedName("username")
+ private val userName: String? = null
+
+ fun toLoginResult(password: String): LoginResult {
+ var userMessage = message
+ if ("UI" == status) {
+ requests?.forEach { request ->
+ request.id()?.let {
+ if (it.endsWith("TOTPAuthenticationRequest")) {
+ return OAuthResult(status, userName, password, message)
+ } else if (it.endsWith("EmailAuthAuthenticationRequest")) {
+ return EmailAuthResult(status, userName, password, message)
+ } else if (it.endsWith("PasswordAuthenticationRequest")) {
+ return ResetPasswordResult(status, userName, password, message)
+ }
+ }
+ }
+ } else if ("PASS" != status && "FAIL" != status) {
+ // TODO: String resource -- Looks like needed for others in this class too
+ userMessage = "An unknown error occurred."
+ }
+ return Result(status ?: "", userName, password, userMessage)
+ }
+}
+
+internal class Request {
+ private val id: String? = null
+ private val required: String? = null
+ private val provider: String? = null
+ private val account: String? = null
+ internal val fields: Map? = null
+
+ fun id(): String? = id
+}
+
+internal class RequestField {
+ private val type: String? = null
+ private val label: String? = null
+ internal val help: String? = null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
new file mode 100644
index 000000000..99abaeeec
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.auth.login
+
+sealed class LoginResult(
+ val status: String,
+ val userName: String?,
+ val password: String?,
+ val message: String?,
+) {
+ var userId = 0
+ var groups = emptySet()
+ val pass: Boolean get() = "PASS" == status
+
+ class Result(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class OAuthResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class EmailAuthResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class ResetPasswordResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/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/caching/CacheController.java b/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
deleted file mode 100644
index 28215b8b2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
+++ /dev/null
@@ -1,89 +0,0 @@
-package fr.free.nrw.commons.caching;
-
-import android.util.Log;
-
-import com.github.varunpant.quadtree.Point;
-import com.github.varunpant.quadtree.QuadTree;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import fr.free.nrw.commons.upload.MwVolleyApi;
-
-public class CacheController {
-
- private double x, y;
- private QuadTree> quadTree;
- private Point>[] pointsFound;
- private double xMinus, xPlus, yMinus, yPlus;
-
- private static final String TAG = CacheController.class.getName();
- private static final int EARTH_RADIUS = 6378137;
-
- public CacheController() {
- quadTree = new QuadTree<>(-180, -90, +180, +90);
- }
-
- public void setQtPoint(double decLongitude, double decLatitude) {
- x = decLongitude;
- y = decLatitude;
- Log.d(TAG, "New QuadTree created");
- Log.d(TAG, "X (longitude) value: " + x + ", Y (latitude) value: " + y);
- }
-
- public void cacheCategory() {
- List pointCatList = new ArrayList<>();
- if (MwVolleyApi.GpsCatExists.getGpsCatExists() == true) {
- pointCatList.addAll(MwVolleyApi.getGpsCat());
- Log.d(TAG, "Categories being cached: " + pointCatList);
- } else {
- Log.d(TAG, "No categories found, so no categories cached");
- }
- quadTree.set(x, y, pointCatList);
- }
-
- public List findCategory() {
- //Convert decLatitude and decLongitude to a coordinate offset range
- convertCoordRange();
- pointsFound = quadTree.searchWithin(xMinus, yMinus, xPlus, yPlus);
- List displayCatList = new ArrayList<>();
- Log.d(TAG, "Points found in quadtree: " + Arrays.asList(pointsFound));
-
- if (pointsFound.length != 0) {
- Log.d(TAG, "Entering for loop");
-
- for (Point> point : pointsFound) {
- Log.d(TAG, "Nearby point: " + point.toString());
- displayCatList = point.getValue();
- Log.d(TAG, "Nearby cat: " + point.getValue());
- }
-
- Log.d(TAG, "Categories found in cache: " + displayCatList.toString());
- } else {
- Log.d(TAG, "No categories found in cache");
- }
- return displayCatList;
- }
-
- //Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters
- public void convertCoordRange() {
- //Position, decimal degrees
- double lat = y;
- double lon = x;
-
- //offsets in meters
- double offset = 100;
-
- //Coordinate offsets in radians
- double dLat = offset/EARTH_RADIUS;
- double dLon = offset/(EARTH_RADIUS*Math.cos(Math.PI*lat/180));
-
- //OffsetPosition, decimal degrees
- yPlus = lat + dLat * 180/Math.PI;
- yMinus = lat - dLat * 180/Math.PI;
- xPlus = lon + dLon * 180/Math.PI;
- xMinus = lon - dLon * 180/Math.PI;
- Log.d(TAG, "Search within: xMinus=" + xMinus + ", yMinus=" + yMinus + ", xPlus=" + xPlus + ", yPlus=" + yPlus);
- }
-}
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/CategoriesAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesAdapter.java
deleted file mode 100644
index 3359150c5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesAdapter.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package fr.free.nrw.commons.category;
-
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.CheckedTextView;
-
-import java.util.ArrayList;
-
-import fr.free.nrw.commons.R;
-
-public class CategoriesAdapter extends BaseAdapter {
-
- private Context context;
- private LayoutInflater mInflater;
-
- private ArrayList items;
-
- public CategoriesAdapter(Context context, ArrayList items) {
- this.context = context;
- this.items = items;
- mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
- }
-
- @Override
- public int getCount() {
- return items.size();
- }
-
- @Override
- public Object getItem(int i) {
- return items.get(i);
- }
-
- public ArrayList getItems() {
- return items;
- }
-
- public void setItems(ArrayList items) {
- this.items = items;
- }
-
- @Override
- public long getItemId(int i) {
- return i;
- }
-
- @Override
- public View getView(int i, View view, ViewGroup viewGroup) {
- CheckedTextView checkedView;
-
- if(view == null) {
- checkedView = (CheckedTextView) mInflater.inflate(R.layout.layout_categories_item, null);
-
- } else {
- checkedView = (CheckedTextView) view;
- }
-
- CategorizationFragment.CategoryItem item = (CategorizationFragment.CategoryItem) this.getItem(i);
- checkedView.setChecked(item.selected);
- checkedView.setText(item.name);
- checkedView.setTag(i);
-
- return checkedView;
- }
-}
\ No newline at end of file
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/CategorizationFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java
deleted file mode 100644
index b4f9d7078..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java
+++ /dev/null
@@ -1,590 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.app.Activity;
-import android.content.ContentProviderClient;
-import android.content.DialogInterface;
-import android.content.SharedPreferences;
-import android.database.Cursor;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.os.RemoteException;
-import android.preference.PreferenceManager;
-import android.support.v4.app.Fragment;
-import android.support.v7.app.AlertDialog;
-import android.text.Editable;
-import android.text.TextUtils;
-import android.text.TextWatcher;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.CheckedTextView;
-import android.widget.EditText;
-import android.widget.ListView;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.upload.MwVolleyApi;
-
-/**
- * Displays the category suggestion and selection screen. Category search is initiated here.
- */
-public class CategorizationFragment extends Fragment {
- public interface OnCategoriesSaveHandler {
- void onCategoriesSave(ArrayList categories);
- }
-
- ListView categoriesList;
- protected EditText categoriesFilter;
- ProgressBar categoriesSearchInProgress;
- TextView categoriesNotFoundView;
- TextView categoriesSkip;
-
- CategoriesAdapter categoriesAdapter;
- ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2);
-
- private OnCategoriesSaveHandler onCategoriesSaveHandler;
-
- protected HashMap> categoriesCache;
-
- private ArrayList selectedCategories = new ArrayList<>();
-
- // LHS guarantees ordered insertions, allowing for prioritized method A results
- private final Set results = new LinkedHashSet<>();
- PrefixUpdater prefixUpdaterSub;
- MethodAUpdater methodAUpdaterSub;
-
- private final ArrayList titleCatItems = new ArrayList<>();
- final CountDownLatch mergeLatch = new CountDownLatch(1);
-
- private ContentProviderClient client;
-
- protected final static int SEARCH_CATS_LIMIT = 25;
- private static final String TAG = CategorizationFragment.class.getName();
-
- public static class CategoryItem implements Parcelable {
- public String name;
- public boolean selected;
-
- public static Creator CREATOR = new Creator() {
- @Override
- public CategoryItem createFromParcel(Parcel parcel) {
- return new CategoryItem(parcel);
- }
-
- @Override
- public CategoryItem[] newArray(int i) {
- return new CategoryItem[0];
- }
- };
-
- public CategoryItem(String name, boolean selected) {
- this.name = name;
- this.selected = selected;
- }
-
- public CategoryItem(Parcel in) {
- name = in.readString();
- selected = in.readInt() == 1;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel parcel, int flags) {
- parcel.writeString(name);
- parcel.writeInt(selected ? 1 : 0);
- }
- }
-
- /**
- * Retrieves category suggestions from title input
- * @return a list containing title-related categories
- */
- protected ArrayList titleCatQuery() {
-
- TitleCategories titleCategoriesSub;
-
- //Retrieve the title that was saved when user tapped submit icon
- SharedPreferences titleDesc = PreferenceManager.getDefaultSharedPreferences(getActivity());
- String title = titleDesc.getString("Title", "");
- Log.d(TAG, "Title: " + title);
-
- //Override onPostExecute to access the results of async API call
- titleCategoriesSub = new TitleCategories(title) {
- @Override
- protected void onPostExecute(ArrayList result) {
- super.onPostExecute(result);
- Log.d(TAG, "Results in onPostExecute: " + result);
- titleCatItems.addAll(result);
- Log.d(TAG, "TitleCatItems in onPostExecute: " + titleCatItems);
- mergeLatch.countDown();
- }
- };
-
- titleCategoriesSub.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- Log.d(TAG, "TitleCatItems in titleCatQuery: " + titleCatItems);
-
- //Only return titleCatItems after API call has finished
- try {
- mergeLatch.await(5L, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- Log.e(TAG, "Interrupted exception: ", e);
- }
- return titleCatItems;
- }
-
- /**
- * Retrieves recently-used categories
- * @return a list containing recent categories
- */
- protected ArrayList recentCatQuery() {
- ArrayList items = new ArrayList<>();
-
- try {
- Cursor cursor = client.query(
- CategoryContentProvider.BASE_URI,
- Category.Table.ALL_FIELDS,
- null,
- new String[]{},
- Category.Table.COLUMN_LAST_USED + " DESC");
- // fixme add a limit on the original query instead of falling out of the loop?
- while (cursor.moveToNext() && cursor.getPosition() < SEARCH_CATS_LIMIT) {
- Category cat = Category.fromCursor(cursor);
- items.add(cat.getName());
- }
- cursor.close();
- }
- catch (RemoteException e) {
- throw new RuntimeException(e);
- }
- return items;
- }
-
- /**
- * Merges nearby categories, categories suggested based on title, and recent categories... without duplicates.
- * @return a list containing merged categories
- */
- protected ArrayList mergeItems() {
-
- Set mergedItems = new LinkedHashSet<>();
-
- Log.d(TAG, "Calling APIs for GPS cats, title cats and recent cats...");
-
- List gpsItems = new ArrayList<>();
- if (MwVolleyApi.GpsCatExists.getGpsCatExists()) {
- gpsItems.addAll(MwVolleyApi.getGpsCat());
- }
- List titleItems = new ArrayList<>(titleCatQuery());
- List recentItems = new ArrayList<>(recentCatQuery());
-
- //Await results of titleItems, which is likely to come in last
- try {
- mergeLatch.await(5L, TimeUnit.SECONDS);
- Log.d(TAG, "Waited for merge");
- } catch (InterruptedException e) {
- Log.e(TAG, "Interrupted Exception: ", e);
- }
-
- mergedItems.addAll(gpsItems);
- Log.d(TAG, "Adding GPS items: " + gpsItems);
- mergedItems.addAll(titleItems);
- Log.d(TAG, "Adding title items: " + titleItems);
- mergedItems.addAll(recentItems);
- Log.d(TAG, "Adding recent items: " + recentItems);
-
- //Needs to be an ArrayList and not a List unless we want to modify a big portion of preexisting code
- ArrayList mergedItemsList = new ArrayList<>(mergedItems);
-
- Log.d(TAG, "Merged item list: " + mergedItemsList);
- return mergedItemsList;
- }
-
- /**
- * Displays categories found to the user as they type in the search box
- * @param categories a list of all categories found for the search string
- * @param filter the search string
- */
- protected void setCatsAfterAsync(ArrayList categories, String filter) {
-
- if (getActivity() != null) {
- ArrayList items = new ArrayList<>();
- HashSet existingKeys = new HashSet<>();
- for (CategoryItem item : categoriesAdapter.getItems()) {
- if (item.selected) {
- items.add(item);
- existingKeys.add(item.name);
- }
- }
- for (String category : categories) {
- if (!existingKeys.contains(category)) {
- items.add(new CategoryItem(category, false));
- }
- }
-
- categoriesAdapter.setItems(items);
- categoriesAdapter.notifyDataSetInvalidated();
- categoriesSearchInProgress.setVisibility(View.GONE);
-
- if (categories.isEmpty()) {
- if (TextUtils.isEmpty(filter)) {
- // If we found no recent cats, show the skip message!
- categoriesSkip.setVisibility(View.VISIBLE);
- } else {
- categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter));
- categoriesNotFoundView.setVisibility(View.VISIBLE);
- }
- } else {
- categoriesList.smoothScrollToPosition(existingKeys.size());
- }
- }
- else {
- Log.e(TAG, "Error: Fragment is null");
- }
- }
-
-
- /**
- * Makes asynchronous calls to the Commons MediaWiki API via anonymous subclasses of
- * 'MethodAUpdater' and 'PrefixUpdater'. Some of their methods are overridden in order to
- * aggregate the results. A CountDownLatch is used to ensure that MethodA results are shown
- * above Prefix results.
- */
- private void requestSearchResults() {
-
- final CountDownLatch latch = new CountDownLatch(1);
-
- prefixUpdaterSub = new PrefixUpdater(this) {
- @Override
- protected ArrayList doInBackground(Void... voids) {
- ArrayList result = new ArrayList<>();
- try {
- result = super.doInBackground();
- latch.await();
- }
- catch (InterruptedException e) {
- Log.w(TAG, e);
- //Thread.currentThread().interrupt();
- }
- return result;
- }
-
- @Override
- protected void onPostExecute(ArrayList result) {
- super.onPostExecute(result);
-
- results.addAll(result);
- Log.d(TAG, "Prefix result: " + result);
-
- String filter = categoriesFilter.getText().toString();
- ArrayList resultsList = new ArrayList<>(results);
- categoriesCache.put(filter, resultsList);
- Log.d(TAG, "Final results List: " + resultsList);
-
- categoriesAdapter.notifyDataSetChanged();
- setCatsAfterAsync(resultsList, filter);
- }
- };
-
- methodAUpdaterSub = new MethodAUpdater(this) {
- @Override
- protected void onPostExecute(ArrayList result) {
- results.clear();
- super.onPostExecute(result);
-
- results.addAll(result);
- Log.d(TAG, "Method A result: " + result);
- categoriesAdapter.notifyDataSetChanged();
-
- latch.countDown();
- }
- };
- prefixUpdaterSub.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- methodAUpdaterSub.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- }
-
- private void startUpdatingCategoryList() {
-
- if (prefixUpdaterSub != null) {
- prefixUpdaterSub.cancel(true);
- }
-
- if (methodAUpdaterSub != null) {
- methodAUpdaterSub.cancel(true);
- }
-
- requestSearchResults();
- }
-
- public int getCurrentSelectedCount() {
- int count = 0;
- for(CategoryItem item: categoriesAdapter.getItems()) {
- if(item.selected) {
- count++;
- }
- }
- return count;
- }
-
- private Category lookupCategory(String name) {
- Cursor cursor = null;
- try {
- cursor = client.query(
- CategoryContentProvider.BASE_URI,
- Category.Table.ALL_FIELDS,
- Category.Table.COLUMN_NAME + "=?",
- new String[] {name},
- null);
- if (cursor.moveToFirst()) {
- Category cat = Category.fromCursor(cursor);
- return cat;
- }
- } catch (RemoteException e) {
- // This feels lazy, but to hell with checked exceptions. :)
- throw new RuntimeException(e);
- } finally {
- if ( cursor != null ) {
- cursor.close();
- }
- }
-
- // Newly used category...
- Category cat = new Category();
- cat.setName(name);
- cat.setLastUsed(new Date());
- cat.setTimesUsed(0);
- return cat;
- }
-
- private class CategoryCountUpdater extends AsyncTask {
-
- private String name;
-
- public CategoryCountUpdater(String name) {
- this.name = name;
- }
-
- @Override
- protected Void doInBackground(Void... voids) {
- Category cat = lookupCategory(name);
- cat.incTimesUsed();
-
- cat.setContentProviderClient(client);
- cat.save();
-
- return null; // Make the compiler happy.
- }
- }
-
- private void updateCategoryCount(String name) {
- new CategoryCountUpdater(name).executeOnExecutor(executor);
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View rootView = inflater.inflate(R.layout.fragment_categorization, null);
- categoriesList = (ListView) rootView.findViewById(R.id.categoriesListBox);
- categoriesFilter = (EditText) rootView.findViewById(R.id.categoriesSearchBox);
- categoriesSearchInProgress = (ProgressBar) rootView.findViewById(R.id.categoriesSearchInProgress);
- categoriesNotFoundView = (TextView) rootView.findViewById(R.id.categoriesNotFound);
- categoriesSkip = (TextView) rootView.findViewById(R.id.categoriesExplanation);
-
- categoriesSkip.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- getActivity().onBackPressed();
- getActivity().finish();
- }
- });
-
- ArrayList items;
- if(savedInstanceState == null) {
- items = new ArrayList<>();
- categoriesCache = new HashMap<>();
- } else {
- items = savedInstanceState.getParcelableArrayList("currentCategories");
- categoriesCache = (HashMap>) savedInstanceState.getSerializable("categoriesCache");
- }
-
- categoriesAdapter = new CategoriesAdapter(getActivity(), items);
- categoriesList.setAdapter(categoriesAdapter);
-
- categoriesList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
- @Override
- public void onItemClick(AdapterView> adapterView, View view, int index, long id) {
- CheckedTextView checkedView = (CheckedTextView) view;
- CategoryItem item = (CategoryItem) adapterView.getAdapter().getItem(index);
- item.selected = !item.selected;
- checkedView.setChecked(item.selected);
- if (item.selected) {
- updateCategoryCount(item.name);
- }
- }
- });
-
- categoriesFilter.addTextChangedListener(new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {
- }
-
- @Override
- public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
- startUpdatingCategoryList();
- }
-
- @Override
- public void afterTextChanged(Editable editable) {
-
- }
- });
-
- startUpdatingCategoryList();
-
- return rootView;
- }
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
- menu.clear();
- inflater.inflate(R.menu.fragment_categorization, menu);
- }
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- getActivity().setTitle(R.string.categories_activity_title);
- client = getActivity().getContentResolver().acquireContentProviderClient(CategoryContentProvider.AUTHORITY);
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- View rootView = getView();
- if (rootView != null) {
- rootView.setFocusableInTouchMode(true);
- rootView.requestFocus();
- rootView.setOnKeyListener(new View.OnKeyListener() {
- @Override
- public boolean onKey(View v, int keyCode, KeyEvent event) {
- if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
- backButtonDialog();
- return true;
- }
- return false;
- }
- });
- }
- }
-
- private void backButtonDialog() {
- AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
-
- builder.setMessage("Are you sure you want to go back? The image will not have any categories saved.")
- .setTitle("Warning");
- builder.setPositiveButton("No", new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int id) {
- //No need to do anything, user remains on categorization screen
- }
- });
- builder.setNegativeButton("Yes", new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int id) {
- getActivity().finish();
- }
- });
-
- AlertDialog dialog = builder.create();
- dialog.show();
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- client.release();
- }
-
- @Override
- public void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putParcelableArrayList("currentCategories", categoriesAdapter.getItems());
- outState.putSerializable("categoriesCache", categoriesCache);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem menuItem) {
- switch(menuItem.getItemId()) {
- case R.id.menu_save_categories:
-
- int numberSelected = 0;
-
- for(CategoryItem item: categoriesAdapter.getItems()) {
- if(item.selected) {
- selectedCategories.add(item.name);
- numberSelected++;
- }
- }
-
- //If no categories selected, display warning to user
- if (numberSelected == 0) {
- AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
-
- builder.setMessage("Images without categories are rarely usable. Are you sure you want to submit without selecting categories?")
- .setTitle("No Categories Selected");
- builder.setPositiveButton("No, go back", new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int id) {
- //Exit menuItem so user can select their categories
- return;
- }
- });
- builder.setNegativeButton("Yes, submit", new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int id) {
- //Proceed to submission
- onCategoriesSaveHandler.onCategoriesSave(selectedCategories);
- return;
- }
- });
-
- AlertDialog dialog = builder.create();
- dialog.show();
- } else {
- //Proceed to submission
- onCategoriesSaveHandler.onCategoriesSave(selectedCategories);
- return true;
- }
- }
- return super.onOptionsItemSelected(menuItem);
- }
-
- @Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- onCategoriesSaveHandler = (OnCategoriesSaveHandler) activity;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.java b/app/src/main/java/fr/free/nrw/commons/category/Category.java
deleted file mode 100644
index 645b10afc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/Category.java
+++ /dev/null
@@ -1,142 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.ContentProviderClient;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-import android.os.RemoteException;
-
-import java.util.Date;
-
-public class Category {
- private ContentProviderClient client;
- private Uri contentUri;
-
- private String name;
- private Date lastUsed;
- private int timesUsed;
-
- // Getters/setters
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public Date getLastUsed() {
- // warning: Date objects are mutable.
- return (Date)lastUsed.clone();
- }
-
- public void setLastUsed(Date lastUsed) {
- // warning: Date objects are mutable.
- this.lastUsed = (Date)lastUsed.clone();
- }
-
- public void touch() {
- lastUsed = new Date();
- }
-
- public int getTimesUsed() {
- return timesUsed;
- }
-
- public void setTimesUsed(int timesUsed) {
- this.timesUsed = timesUsed;
- }
-
- public void incTimesUsed() {
- timesUsed++;
- touch();
- }
-
- // Database/content-provider stuff
- public void setContentProviderClient(ContentProviderClient client) {
- this.client = client;
- }
-
- public void save() {
- try {
- if(contentUri == null) {
- contentUri = client.insert(CategoryContentProvider.BASE_URI, this.toContentValues());
- } else {
- client.update(contentUri, toContentValues(), null, null);
- }
- } catch(RemoteException e) {
- throw new RuntimeException(e);
- }
- }
-
- public ContentValues toContentValues() {
- ContentValues cv = new ContentValues();
- cv.put(Table.COLUMN_NAME, getName());
- cv.put(Table.COLUMN_LAST_USED, getLastUsed().getTime());
- cv.put(Table.COLUMN_TIMES_USED, getTimesUsed());
- return cv;
- }
-
- public static Category fromCursor(Cursor cursor) {
- // Hardcoding column positions!
- Category c = new Category();
- c.contentUri = CategoryContentProvider.uriForId(cursor.getInt(0));
- c.name = cursor.getString(1);
- c.lastUsed = new Date(cursor.getLong(2));
- c.timesUsed = cursor.getInt(3);
- return c;
- }
-
- public static class Table {
- public static final String TABLE_NAME = "categories";
-
- public static final String COLUMN_ID = "_id";
- public static final String COLUMN_NAME = "name";
- public static final String COLUMN_LAST_USED = "last_used";
- public static final String COLUMN_TIMES_USED = "times_used";
-
- // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
- public static final String[] ALL_FIELDS = {
- COLUMN_ID,
- COLUMN_NAME,
- COLUMN_LAST_USED,
- COLUMN_TIMES_USED
- };
-
- private static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
- + COLUMN_ID + " INTEGER PRIMARY KEY,"
- + COLUMN_NAME + " STRING,"
- + COLUMN_LAST_USED + " INTEGER,"
- + COLUMN_TIMES_USED + " INTEGER"
- + ");";
-
- public static void onCreate(SQLiteDatabase db) {
- db.execSQL(CREATE_TABLE_STATEMENT);
- }
-
- public static void onUpdate(SQLiteDatabase db, int from, int to) {
- if(from == to) {
- return;
- }
- if(from < 4) {
- // doesn't exist yet
- from++;
- onUpdate(db, from, to);
- return;
- }
- if(from == 4) {
- // table added in version 5
- onCreate(db);
- from++;
- onUpdate(db, from, to);
- return;
- }
- if(from == 5) {
- from++;
- onUpdate(db, from, to);
- return;
- }
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/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