+
+
+
+
\ 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 5e76e09d9..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-language: android
-
-addons:
- apt:
- packages:
- - w3m
-
-env:
- global:
- - ANDROID_TARGET=android-22
- - ANDROID_ABI=armeabi-v7a
- - ADB_INSTALL_TIMEOUT=12 # in minutes
-
-jdk:
- # - openjdk8 # not yet available
- - oraclejdk8
-
-android:
- components:
- - tools
- - platform-tools
- - build-tools-27.0.0
- - extra-google-m2repository
- - extra-android-m2repository
- - ${ANDROID_TARGET}
- - android-25
- - android-26
- - android-27
- - sys-img-${ANDROID_ABI}-${ANDROID_TARGET}
- licenses:
- - 'android-sdk-license-.+'
-
-before_script:
- - echo no | android create avd --force -n test -t $ANDROID_TARGET --abi $ANDROID_ABI
- - emulator -avd test -no-audio -no-window -no-boot-anim &
- - android-wait-for-emulator
-
-script:
- - ./gradlew clean check connectedCheck jacocoTestReport
-
-after_success:
- - bash <(curl -s https://codecov.io/bash)
-
-after_failure:
- - echo '*** Debug Unit Test Results ***'
- - w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/tests/*/classes/*Test.html
- - echo '*** Connected Test Results ***'
- - w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/androidTests/connected/flavors/*/*Test.html
-
-before_cache:
- - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
-
-cache:
- directories:
- - $HOME/.gradle/caches/
- - $HOME/.gradle/wrapper/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46debc88a..575aa6a32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,437 @@
# Wikimedia Commons for Android
+## v6.0.2
+
+### What's changed
+* Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard
+* Links in the "File usages" list are now clickable and will take you to the correct page.
+* Titles for file usages are now clearer and easier to understand
+* Bug fixes and stability improvements
+
+## v6.0.1
+
+### What's changed
+* The app now supports Android 15 with an improved user interface
+* Enhanced Nearby with robust and more reliable labels
+* Bug fixes and stability improvements
+
+## v5.6.1
+
+### What's changed
+* The app no longer uploads images to Wikidata if one exists already for a given item
+* File usage displays correctly now
+* No more infinite circular progress bar on nominating an image for deletion
+* Enhanced location updates while using GPS
+* Author/uploader names are now available in Media Details for Commons licensing compliance
+* Improved usage of popups in Nearby
+* Bug fixes and stability improvements
+
+## v5.5.0
+
+### What's changed
+* Explore images will now be shown based on the map location and not at your current location
+* Enhanced Wikidata feedback message
+* Green labels in Explore map will no longer be hidden by other pins thumbnails
+* Upload wizard's language drop-down now reflects the language used in the pin label
+* Users can now pick only one image at a time while using the custom selector
+* Bug fixes and stability improvements
+
+## v5.4.1
+
+### What's changed
+* Custom picker now detects images that are already available on Commons
+* Improve credit line in image list
+* Show place cards with loaded names only in the Nearby list
+* Fix the error that occurs while loading images in Explore
+
+## v5.3.0
+
+### What's changed
+* Enable EmailAuth support
+* Explore map images no longer show "Unknown"
+* Fix crash when removing last two images of multiupload
+* Mark ❌ for closed locations (P3999) in Nearby
+* Fix two pin labels staying visible at the same time in Explore map
+* Refactoring and minor UI improvements
+
+## v5.2.0
+
+v5.2.0 boasts several new functionalities like:
+
+* A new refresh button lets you quickly reload the Nearby map
+* Bookmarks now support categories
+* Improved feedback and consistency in the user interface
+* Bug fixes and performance improvements
+
+### What's changed
+* Implement "Refresh" button to clear the cache and reload the Nearby map.
+* `CommonsApplication` migrate to kotlin & some lint fixes.
+* Revert back to MainScope for database and UI updates and make database operations thread safe.
+* Hide edit options for logged-out users in Explore screen.
+* Introduced a button to delete the current folder in custom selector.
+* Improve Unique File Name Search.
+* Migration of several modules from Java to Kotlin.
+* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins.
+* Bug fixes and enhancement of Achievements screen.
+* Show where file is being used on Commons and other wikis.
+* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices.
+* Make dialogs modal and always show the upload icon.
+* Fix unintentional deletion of subfolders and non-images by custom selector.
+* Bookmark categories.
+* Add pull down to refresh in the Contributions screen.
+* Fix race condition and lag when loading pin details, faster overlay management.
+* Show cached pins in Nearby even when internet is unavailable
+
+ Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0).
+
+
+## v5.1.2
+
+### What's changed
+
+* Fix the broken category search in the explore screen
+
+## v5.1.1
+
+### What's changed
+
+* Use Android's new EXIF interface to mitigate security issues in old
+ EXIF interface.
+* Make the icon that helps view the upload queue always visible as it ensures
+ that the queue accessible at all times.
+
+## v5.1.0
+
+### What's Changed
+
+* Enhanced **upload queue management** in the Commons app for smoother, sequential
+ processing, clearer progress tracking, prevention of stuck or duplicate
+ uploads. As part of this improvement, the "Limited Connection mode" has been
+ removed.
+* Added an option in "Nearby" feature enabling users to **provide feedback on
+ Wikidata items**. Users can report if an item doesn’t exist, is at a different
+ location, or has other issues, with submissions tagged for easy tracking and
+ updates.
+* Improved the "Nearby" feature by splitting the query into two parts for faster
+ loading and **better performance, especially in areas with dense amount of
+ places**. This update also resolves issues with pins overlapping place names.
+* Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to
+ the app such as adding **"Partial Access" support**. Also includes some minor
+ refactoring, and replacement of deprecated circular progress bars.
+* Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs
+ appeared blank** in the Category Details screen. Resolved by optimizing view
+ binding handling in the parent fragments.
+* Fixed an issue where editing depictions removed all other structured data from
+ images. Now, **only depictions are updated, preserving other associated data**.
+* Fixed **map centering** in the image upload flow to **use GPS EXIF tag location**
+ from pictures and ensured "Show in map app" accurately reflects this location.
+* Fixed navigation **after uploading via Nearby by directing users to the Uploads
+ activity** instead of returning to Nearby, preventing confusion about needing to
+ upload again.
+
+### Bug fixes and various changes
+
+* Improved the "Nearby" feature to fetch labels based on the user's preferred
+ language instead of defaulting to English.
+* Added a legend to the "Nearby" feature indicating pin statuses: red for items
+ without pictures, green for those with pictures, and grey for items being
+ checked. A floating action button now allows users to toggle the legend's
+ visibility.
+* Fixed an issue where the "Nominate for deletion" option is shown to logged out
+ users, preventing app errors and crashes.
+* Updated the regex pattern that filters categories with an year in it to also
+ filter the 2020s.
+* Fix an issue where past depictions were not shown as suggestions, despite
+ being saved correctly.
+* Fixed an issue in custom image picker where exiting the media preview showed
+ only the first image and cleared selections. Now, previously selected images
+ are restored correctly after exiting the preview. This was contributed.
+* Fixed an issue in custom image picker where scrolling behavior did not
+ maintain position after exiting fullscreen preview, ensuring users remain at
+ the same point in their image roll unless actioned images are filtered. This
+ was contributed.
+* Fixed Nearby map not showing new pins on map move by removing the 2000m scroll
+ threshold and adding an 800ms debounce for smoother pin updates when the map
+ is moved. Queued searches are now canceled on fragment destruction.
+* Revised author information retrieval to emphasize the custom author name from
+ the metadata instead of the default registered username.
+* Enhanced notification classification to properly identify "email" type
+ notifications and prompting users to check their e-mail inbox when such
+ notifications are clicked.
+* Resolved a bug in the language chooser that incorrectly greyed-out previously
+ selected languages, ensuring only the current language is non-selectable during
+ image upload.
+* Resolved pin color update issue in "Nearby" feature where the pin colour
+ failed to be updated after a successful image upload.
+
+What's listed here is only a subset of all the changes. Check the full-list of
+the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0).
+Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0)
+for an exhaustive list of changes and the various contributors who contributed the same.
+
+## v5.0.2
+
+- Enhanced multi-upload functionality with user prompts to clarify that all images would share the
+ same category and depictions.
+- Show Wikidata description on currently active Nearby pin to provide more useful information.
+- Improve the visibility of map markers by dynamically adjusting their colors based on the app's
+ theme. The map markers will now appear lighter when the app is in dark mode and darker when the
+ app is in light mode. This change aims to enhance marker visibility and improve the overall user
+ experience.
+- Added information on where user feedback is posted, helping users track existing feedback and
+ monitor their own submissions.
+- Enhanced the edit location screen of the upload screen by centering the map on the picture's
+ location from metadata when editing, or on the device's GPS location if metadata is unavailable,
+ improving accuracy and user experience.
+- Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a
+ recently uploaded image, enhancing clarity and user experience.
+- Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user
+ experience by showing loading progress until the image is fully loaded.
+- Fixed an issue where caption and description fields would intermittently disappear when using
+ voice input, ensuring text remains visible and stable across all entries.
+- Fixed a crash that occurred when attempting to remove multiple instances of caption/description
+ fields after initially adding them.
+- Improve the text in the prompt shown when skipping login to sound more natural.
+- Modified feedback addition logic to append new sections at the bottom of the page, ensuring
+ auto-archiving of sections functions correctly on the feedback page.
+- Resolved issue where the app failed to clear cookies upon logout.
+
+## v5.0.1
+
+Same as v5.0.0 except this fixes some R8 rules to ensure that the release
+variants of the app work as intended.
+
+## v5.0.0
+
+### What's Changed
+
+- Redesigned the map feature to **replace Mapbox with the osmdroid library**.
+ Key elements like pin visualization and user-centered display are still
+ included in this redesign. This is done to guard against possible misuse of
+ the Mapbox token and, more crucially, to keep the app from becoming dependent
+ on a service that charges for usage but offers a free tier.
+
+ With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org).
+- Add the ability to **export locations of nearby missing pictures in GPX and
+ KML formats**. This allows users to browse the locations with desired radius
+ for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing
+ accessibility and offline functionality.
+- **Limited the uploads via the custom image picker** to a maximum of 20.
+- Added two menu choices for **transparent image backgrounds**, giving users the
+ option of either a black or white background, increasing adaptability to
+ various theme settings.
+
+ User customization option has been provided with the
+ ability to save background color selections permanently on a per image basis.
+- Implemented functionality to **automatically resume uploads** that become
+ stuck due to app termination or device reboot.
+- Added a **compass arrow in the Nearby banner** shown in the "Contributions"
+ screen to guide users towards the nearest item, thus providing the missing
+ directional cues. The arrow dynamically adjusts based on device rotation,
+ aligning with the calculated bearing towards the target location. Further,
+ the distance and direction are updated as the user moves.
+- Implemented **voice input feature** for caption and description fields,
+ enabling users to dictate text directly into these fields.
+- Improved various flows in the app to **redirect users to the login page** and
+ display a persistent message **if their session becomes invalid** due to a
+ password change, enhancing user guidance and security measures.
+
+### Revamps and refactorings
+
+- **Revamped initial upload screen layout and the description edit screen layout**
+ for enhanced user experience and ensuring better symmetry in the design.
+- **Replaced Butterknife with ViewBinding** in various places of the app.
+- Transferred essential code from **the redundant data-client module** to the
+ main Commons app code, enabling its integration and facilitating the removal
+ of the redundant module. Further, convert various parts of the code to Kotlin.
+- **Revamped the various location permission flows** to ensure consistency for
+ the sake of a better user experience.
+
+### Bug fixes and various changes
+
+- Resolved an issue where paused uploads that were subsequently cancelled were
+ still being uploaded.
+- Fixed an issue where some user information such as upload count were not
+ displayed in the "Contributions" and "Profile" screens.
+- Fixed the long-standing broken *"Picture of the Day" widget* to restore its
+ usability.
+- Resolved an issue where some categories were hidden at the top of Upload
+ Wizard suggestions.
+- Resolved an issue where there was a grey empty screen at Upload wizard when
+ the app was denied the files permission.
+- Implemented logic to bypass media in Peer Review if the current reviewer is
+ also the user who uploaded the media.
+- Corrected arrow image behaviour in the first upload screen: now displays down
+ arrow when details card is fully visible, aligning with expected user
+ interaction.
+- Updated app icon to improve visibility and recognition on F-Droid.
+- Fixed issue causing all pictures to disappear and activity to reload fully in
+ the custom image selector after marking a picture as 'not for upload', now
+ ensuring only the selected picture is removed as expected.
+
+What's listed here is only a subset of all the changes. Check the full-list of
+the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0).
+Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0)
+for an exhaustive list of changes and the various contributors who contributed the same.
+
+## v4.2.1
+
+- Provide the ability to edit an image to losslessly rotate it while uploading
+- Fix a bug in v4.2.0 where the nearby places were not loading
+- Fix a bug where editing depictions was showing a progress bar indefinitely
+- In the upload screen, use different map icons to indicate if image is being uploaded with location
+ metadata
+- For nearby uploads, it is no longer possible to deselect the item's category and depiction
+- The Mapbox account key used by the app has been changed
+- Category search now shows exact matches without any discrepancies
+- Various bug and crash fixes
+
+## v4.2.0
+- Dark mode colour improvements
+- Enhancements done to address location metadata loss including the metadata loss that occurs in
+ latest Android versions
+- Enhancements done to address the issue where uploads get stuck in queued state
+- Fix the inability to upload via the in-app camera option
+- Provide the ability to optionally include location metadata for in-app camera uploads in case the
+ device camera app does not provide location metadata
+- Use geo location URL that works consistently across all map applications
+- Fix crash when clicking on location target icon while trying to edit the location of an upload
+- Fix crash that occurs randomly while returning to the app after leaving it in the background
+- Fix crash in Sign up activity on Android version 5.0 and 5.1
+- Android 13 compatibility changes
+
+## v4.1.0
+- Location of pictures uploaded via custom picture selector are now recognized
+- Improvements to the custom picture selector
+- Ensure the WLM pictures are associated with the correct templates for each year
+- Only show pictures uploaded via app in peer review
+- Improve the variety of images show in peer review
+- Allow going to current location in location edit dialog while uploading a picture
+- Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox
+- Fixed various bugs
+
+## v4.0.5
+- Bumped min SDK to 29 to try and solve Google policy issue
+- Reverted dialog
+- Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published)
+
+## v4.0.4
+- Added dialog for Google's location policy
+
+## v4.0.3
+- Added "Report" button for Google UGC policy
+
+## v4.0.2
+- Fixed bug with wrong dates taken from EXIF
+- Fixed various crashes
+
+## v4.0.1
+- Fixed bug with no browser found
+- Updated Mapbox SDK to fix hamburger crash
+
+## v4.0.0
+- Added map showing nearby Commons pictures
+- Added custom SPARQL queries
+- Added user profiles
+- Added custom picture selector
+- Various bugfixes
+- Updated target SDK to 30
+
+## v3.1.1
+- Optimized Nearby query
+- Added Sweden's property for WLM 2021
+- Added link to wiki explaining how to contribute to WLM through app
+- Fixed various bugs and crashes
+
+## v3.1.0
+- Added Wiki Loves Monuments integration for WLM 2021
+
+## v3.0.2
+- Fixed crash when uploading high res image
+- Fixed crash when viewing images in Explore
+
+## v3.0.1
+- Pre-fill desc in Nearby uploads with Wikidata item's label + description
+- Improved ACRA crash reporting
+- Fixed various crashes
+
+## v3.0.0
+- Added Structured Data to upload workflow, users can now add depicts
+- Added Leaderboard in Achievements screen
+- Added to-do system for images with no categories/descriptions or with associated Wikipedia articles that have no pictures
+- Users can now modify and add categories to their uploads from the media details view
+- New UI for main screen
+- Limited connection mode added, users can now pause and resume uploads
+
+## v2.13.1
+- Added OpenStreetMap attribution
+- Fixed various crashes
+- Fixed SQLite error in Nearby map
+- Fixed issue with Nearby uploads not being associated with Wikidata p18
+
+## v2.13.0
+- New media details UI, ability to zoom and pan around image
+- Added suggestions for a place that needs photos if user uploads a photo that is near one of them
+- Modifications and fixes to Nearby filters based on user feedback
+- Multiple crash and bug fixes
+
+## v2.12.3
+- Fixed issue with EXIF data, including coords, being removed from uploads
+
+## v2.12.2
+- Fixed crash on startup
+
+## v2.12.1
+- Fixed issue with Nearby loading in wrong location
+- Various crash fixes
+
+## v2.12.0
+- Completed codebase overhaul
+- Added filters for place type and place state to Nearby
+- Switched to using new data client library, aimed at fixing failed uploads
+- Fixed 2FA not working
+- Fixed issues with upload date and deletion notifications
+
+## v2.11.0
+- Refactored upload process, explore/media details, and peer review to use MVP architecture
+- Refactored all AsyncTasks to use RxAndroid
+- Partial migration to Retrofit
+- Allow users to remove EXIF tags from their uploads if desired
+- Multiple crash and bug fixes
+
+## v2.10.2
+- Fixed remaining issues with date image taken
+- Fixed database crash
+
+## v2.10.1
+- Fixed "stuck before category selection screen" bug
+- Fixed notification taps
+- Fixed crash while uploading images
+- Fixed crash while loading contributions
+- Fixed sporadic issue with date image was taken
+
+## v2.10.0
+- Added option to search for places that need pictures in any location
+- Added coordinate check for images submitted via Nearby
+- Added news about ongoing campaigns
+- Easy retry for failed uploads
+- Javadocs for Nearby package
+- Optimized Nearby query for faster loading
+- Allow users to dismiss notifications
+- Various bugfixes for Explore, Notifications and Nearby
+- Fixed uploads getting stuck in "receiving shared content" phase
+- Fixed empty notifications bell icon in main screen
+
+## v2.9.0
+- New main screen UI with Nearby tab
+- New upload UI and flow
+- Multiple uploads
+- Send Log File revamp
+- Fixed issues with wrong "image taken" date
+- Fixed default zoom level in Nearby map
+- Incremented target SDK to 27, with corresponding notification channel fix
+- Removed several redundant libraries to reduce bloat
+
## v2.8.5
- Fixed issues with sporadic upload failures due to wrong mimeType
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 37ecfca1f..d8f293a25 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,9 +7,9 @@ Here's a gist of the guidelines,
1. Make separate commits for logically separate changes
-1. Describe your changes well in the commit message
+2. Describe your changes well in the commit message
- The first line of the commit message should be a short description of what has
+The first line of the commit message should be a short description of what has
changed. It is also good to prefix the first line with "area: " where the "area"
is a filename or identifier for the general area of the code being modified.
The body should provide a meaningful commit message.
@@ -29,9 +29,9 @@ The body should provide a meaningful commit message.
and if needed, Git allows us to see who wrote something without needing
to add these tags (`git blame`)
-1. Write tests for your code (if possible)
+2. Write tests for your code (if possible)
-1. Make sure the Wiki pages don't become stale by updating them (if needed)
+3. Make sure the Wiki pages don't become stale by updating them (if needed)
### Further reading
diff --git a/CREDITS b/CREDITS
index 6847ac9b6..3fe6b00d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -40,12 +40,19 @@ their contribution to the product.
* 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
-* MapBox
3rd party open source apps from which significant code has been reused:
* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
deleted file mode 100644
index 8feca4268..000000000
--- a/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,32 +0,0 @@
-**Summary:**
-
-Summarize your issue in one sentence (what goes wrong, what did you expect to happen)
-
-_Before creating an issue, please search the existing issues to see if a similar one has already been created. You can search issues by specific labels (e.g. `label:nearby `) or just by typing keywords into the search filter._
-
-**Steps to reproduce:**
-
-How can we reproduce the issue?
-What did you expect the app to do, and what did you see instead?
-
-**Add System logs:**
-
-Add logcat files here (if possible).
-
-**Device and Android version:**
-
-What make and model device (e.g., Samsung J7) did you encounter this on? What Android
-version (e.g., Android 4.0 Ice Cream Sandwich or Android 6.0 Marshmallow) are you running? Is it
- the stock version from the manufacturer or a custom ROM ?
-
- **Commons app version:**
-
-You can find this information by going to the navigation drawer in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. master and prodDebug).
-
-**Screen-shots:**
-
-Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher.
-
-**Would you like to work on the issue?**
-
-Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them.
diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 37e104d14..000000000
--- a/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,19 +0,0 @@
-## Title (required)
-
-Fixes #{GitHub issue number and title (Please do not forget adding title) }
-
-## Description (required)
-
-Fixes #{GitHub issue number and title}
-
-{Describe the changes made and why they were made.}
-
-## Tests performed (required)
-
-Tested on {API level & name of device/emulator}, with {build variant, e.g. ProdDebug}.
-
-## Screenshots showing what changed (optional)
-
-{Only for user interface changes, otherwise remove this section. See [how to take a screenshot](https://android.stackexchange.com/questions/1759/how-to-take-a-screenshot-with-an-android-device)}
-
-_Note: Please ensure that you have read CONTRIBUTING.md if this is your first pull request._
\ No newline at end of file
diff --git a/README.md b/README.md
index 696201029..37f1a7872 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +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][1], or view our [website][2].
-Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request :-)
+Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request! :-)
-
+
## Documentation
-We try to have an extensive documentation at [our wiki here at Github][5]:
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
-* [User Documentation][6]
-* [Contributor Documentation][7]
- * [Volunteers Welcome!][9]
+* [User Documentation][5]
+* [Contributor Documentation][6]
+ * [Volunteers Welcome!][7]
* [Developer Documentation][8]
+ * [Libraries Used][9]
+
+## Contributors ##
+
+Thank you all for your work!
+
+| [ misaochan](https://github.com/misaochan) | [ translatewiki](https://github.com/translatewiki) | [ neslihanturan](https://github.com/neslihanturan) | [ yuvipanda](https://github.com/yuvipanda) | [ nicolas-raoul](https://github.com/nicolas-raoul) |
+| :---: | :---: | :---: | :---: | :---: |
+| [ psh](https://github.com/psh) | [ domdomegg](https://github.com/domdomegg) | [ maskaravivek](https://github.com/maskaravivek) | [ madhurgupta10](https://github.com/madhurgupta10) | [ ashishkumar468](https://github.com/ashishkumar468) |
+| [ bvibber](https://github.com/bvibber) | [ whym](https://github.com/whym) | [ akaita](https://github.com/akaita) | [ sivaraam](https://github.com/sivaraam) | [ veyndan](https://github.com/veyndan) |
+| [ ujjwalagrawal17](https://github.com/ujjwalagrawal17) | [ macgills](https://github.com/macgills) | [ amire80](https://github.com/amire80) | [ dbrant](https://github.com/dbrant) | [ vanshikaarora](https://github.com/vanshikaarora) |
+| [ RitikaPahwa4444](https://github.com/RitikaPahwa4444) | [ Ayan-10](https://github.com/Ayan-10) | [ rohit9625](https://github.com/rohit9625) | [ shashankiitbhu](https://github.com/shashankiitbhu) | [ Pratham2305](https://github.com/Pratham2305) |
+| [ parneet-guraya](https://github.com/parneet-guraya) | [ sandarumk](https://github.com/sandarumk) | [ tanvidadu](https://github.com/tanvidadu) | [ cypherop](https://github.com/cypherop) | [ Prince-kushwaha](https://github.com/Prince-kushwaha) |
+
+
+
+.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).
## License ##
-This software is open source, licensed under the [Apache License 2.0][4].
-
+This software is open source, licensed under the [Apache License 2.0][10].
[1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
[2]: https://commons-app.github.io/
-[3]: https://github.com/commons-app/apps-android-commons/issues
-[4]: https://www.apache.org/licenses/LICENSE-2.0
-[5]: https://github.com/commons-app/apps-android-commons/wiki
-[6]: https://github.com/commons-app/apps-android-commons/wiki#user-documentation
-[7]: https://github.com/commons-app/apps-android-commons/wiki#contributor-documentation
-[8]: https://github.com/commons-app/apps-android-commons/wiki#developer-documentation
-[9]: https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome%21
-[10]: https://meta.wikimedia.org/wiki/Grants:Project/Improve_%27Upload_to_Commons%27_Android_App/Renewal
+[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
+
+[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation
+[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation
+[6]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#️-contributor-documentation
+[7]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Volunteers-welcome!.md#volunteers-welcome
+[8]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-developer-documentation
+[9]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Libraries-used.md#libraries-used
+
+[10]: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 72e514f6e..000000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,204 +0,0 @@
-apply from: '../gitutils.gradle'
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-kapt'
-apply plugin: 'jacoco-android'
-apply from: 'quality.gradle'
-apply plugin: 'com.getkeepsafe.dexcount'
-
-dependencies {
- implementation 'com.squareup.picasso:picasso:2.71828'
- implementation 'com.prof.rssparser:rssparser:1.1'
- implementation 'com.github.nicolas-raoul:Quadtree:ac16ea8035bf07'
- implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
- implementation 'in.yuvi:http.fluent:1.3'
- implementation 'com.github.chrisbanes:PhotoView:2.0.0'
- implementation 'ch.acra:acra:4.9.2'
- implementation 'org.mediawiki:api:1.3'
- implementation 'commons-codec:commons-codec:1.10'
- implementation 'com.github.pedrovgs:renderers:3.3.3'
- implementation 'com.google.code.gson:gson:2.8.1'
- implementation 'com.jakewharton.timber:timber:4.5.1'
- implementation 'info.debatty:java-string-similarity:0.24'
- implementation 'com.borjabravo:readmoretextview:2.1.0'
-
- implementation 'com.android.support.constraint:constraint-layout:1.1.0'
- implementation('com.mapbox.mapboxsdk:mapbox-android-sdk:5.5.0@aar') {
- transitive = true
- }
- implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
- //noinspection GradleCompatible
- implementation "com.android.support:support-v4:$SUPPORT_LIB_VERSION"
- implementation "com.android.support:appcompat-v7:$SUPPORT_LIB_VERSION"
- implementation "com.android.support:design:$SUPPORT_LIB_VERSION"
- implementation "com.android.support:customtabs:$SUPPORT_LIB_VERSION"
- implementation "com.android.support:cardview-v7:$SUPPORT_LIB_VERSION"
- implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
- kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
- implementation 'com.squareup.okhttp3:okhttp:3.9.1'
- implementation 'com.squareup.okio:okio:1.13.0'
- implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
- // Because RxAndroid releases are few and far between, it is recommended you also
- // explicitly depend on RxJava's latest version for bug fixes and new features.
- implementation 'com.android.support:multidex:1.0.3'
- implementation 'io.reactivex.rxjava2:rxjava:2.1.2'
- implementation 'com.jakewharton.rxbinding2:rxbinding:2.0.0'
- implementation 'com.jakewharton.rxbinding2:rxbinding-support-v4:2.0.0'
- implementation 'com.jakewharton.rxbinding2:rxbinding-appcompat-v7:2.0.0'
- implementation 'com.jakewharton.rxbinding2:rxbinding-design:2.0.0'
- implementation 'org.jsoup:jsoup:1.11.3'
- implementation 'com.facebook.fresco:fresco:1.5.0'
- implementation 'com.facebook.stetho:stetho:1.5.0'
- implementation "com.google.dagger:dagger:$DAGGER_VERSION"
- implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
- kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
- kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
- testImplementation 'org.robolectric:multidex:3.4.2'
- testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
- testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
- testImplementation 'junit:junit:4.12'
- testImplementation 'org.robolectric:robolectric:3.7.1'
- testImplementation 'com.nhaarman:mockito-kotlin:1.5.0'
- testImplementation 'com.squareup.okhttp3:mockwebserver:3.8.1'
- implementation 'com.dinuscxj:circleprogressbar:1.1.1'
-
- implementation 'com.tspoon.traceur:traceur:1.0.1'
- implementation 'com.caverock:androidsvg:1.2.1'
- implementation 'com.github.bumptech.glide:glide:4.7.1'
- kapt 'com.github.bumptech.glide:compiler:4.7.1'
-
- androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
- androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.8.1'
- androidTestImplementation "com.android.support:support-annotations:$SUPPORT_LIB_VERSION"
- androidTestImplementation 'com.android.support.test:rules:1.0.2'
- androidTestImplementation 'com.android.support.test:runner:1.0.2'
- androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
- debugImplementation "com.squareup.leakcanary:leakcanary-android:$LEAK_CANARY"
- releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY"
- testImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY"
-
- implementation 'com.borjabravo:readmoretextview:2.1.0'
- implementation 'com.dinuscxj:circleprogressbar:1.1.1'
- implementation files('libs/simplemagic-1.9.jar')
-}
-
-android {
- compileSdkVersion project.compileSdkVersion
- buildToolsVersion project.buildToolsVersion
-
- useLibrary 'org.apache.http.legacy'
-
- defaultConfig {
- applicationId 'fr.free.nrw.commons'
- versionCode 92
- versionName '2.8.5'
- setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
-
- minSdkVersion project.minSdkVersion
- targetSdkVersion project.targetSdkVersion
- testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
- vectorDrawables.useSupportLibrary = true
-
- multiDexEnabled true
- }
-
- testOptions {
- unitTests.all {
- jvmArgs '-noverify'
- }
- }
-
- sourceSets {
- // use kotlin only in tests (for now)
- test.java.srcDirs += 'src/test/kotlin'
-
- // use main assets and resources in test
- test.assets.srcDirs += 'src/main/assets'
- test.resources.srcDirs += 'src/main/resoures'
- }
-
- 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', 'proguard-glide.txt'
- }
- debug {
- testCoverageEnabled true
- versionNameSuffix "-debug-" + getBranchName() + "~" + getBuildVersion()
- }
- }
-
- flavorDimensions 'tier'
- productFlavors {
- prod {
-
- applicationId 'fr.free.nrw.commons'
-
- buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
- buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\""
- buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
- buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
- buildConfigField "String", "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", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\""
- buildConfigField "String", "EVENTLOG_URL", "\"https://www.wikimedia.org/beacon/event\""
- buildConfigField "String", "EVENTLOG_WIKI", "\"commonswiki\""
- 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", "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\""
-
- dimension 'tier'
- }
-
- beta {
- applicationId 'fr.free.nrw.commons.beta'
-
- // What values do we need to hit the BETA versions of the site / api ?
- buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
- buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\""
- buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
- buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
- buildConfigField "String", "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", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\""
- buildConfigField "String", "EVENTLOG_URL", "\"https://commons.wikimedia.beta.wmflabs.org/beacon/event\""
- buildConfigField "String", "EVENTLOG_WIKI", "\"commonswiki\""
- 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", "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\""
-
- dimension 'tier'
- }
- }
-
- lintOptions {
- disable 'MissingTranslation'
- disable 'ExtraTranslation'
- abortOnError false
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- //FIXME: Temporary fix for https://github.com/commons-app/apps-android-commons/issues/709
- configurations.all {
- resolutionStrategy.force 'com.android.support:support-annotations:25.2.0'
- exclude module: 'httpclient'
- exclude module: 'commons-logging'
- }
- buildToolsVersion buildToolsVersion
-}
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/libs/java-json.jar b/app/libs/java-json.jar
deleted file mode 100644
index 2f211e366..000000000
Binary files a/app/libs/java-json.jar and /dev/null differ
diff --git a/app/libs/simplemagic-1.9.jar b/app/libs/simplemagic-1.9.jar
deleted file mode 100644
index 59533b049..000000000
Binary files a/app/libs/simplemagic-1.9.jar and /dev/null differ
diff --git a/app/proguard-glide.txt b/app/proguard-glide.txt
deleted file mode 100644
index ef3437660..000000000
--- a/app/proguard-glide.txt
+++ /dev/null
@@ -1,9 +0,0 @@
--keep public class * implements com.bumptech.glide.module.GlideModule
--keep public class * extends com.bumptech.glide.module.AppGlideModule
--keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
- **[] $VALUES;
- public *;
-}
-
-# for DexGuard only
--keepresourcexmlelements manifest/application/meta-data@value=GlideModule
\ No newline at end of file
diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt
index 39b618718..21c584ba9 100644
--- a/app/proguard-rules.txt
+++ b/app/proguard-rules.txt
@@ -1,4 +1,100 @@
-dontobfuscate
+-ignorewarnings
+
+-dontnote **
+-dontwarn net.bytebuddy.**
+-dontwarn org.mockito.**
+
+# --- Apache ---
-keep class org.apache.http.** { *; }
--dontwarn org.apache.http.**
--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 1afdf0d68..000000000
--- a/app/quality.gradle
+++ /dev/null
@@ -1,45 +0,0 @@
-apply plugin: 'checkstyle'
-apply plugin: 'pmd'
-
-check.dependsOn 'checkstyle', 'pmd'
-
-checkstyle {
- toolVersion = '7.5.1'
-}
-
-task checkstyle(type: Checkstyle) {
- configFile file("${project.rootDir}/script/style/checkstyle.xml")
- source 'src'
- include '**/*.java'
- exclude '**/gen/**'
-
- classpath = files()
-
- reports {
- html {
- enabled true
- destination file("${project.buildDir}/reports/checkstyle/checkstyle.html")
- }
- }
-}
-
-task pmd(type: Pmd) {
- ignoreFailures = true
- ruleSetFiles = files("${project.rootDir}/script/style/ruleset.xml")
- ruleSets = []
-
- source 'src'
- include '**/*.java'
- exclude '**/gen/**'
-
- reports {
- xml.enabled = false
- html.enabled = true
- xml {
- destination file("${project.buildDir}/reports/pmd/pmd.xml")
- }
- html {
- destination file("${project.buildDir}/reports/pmd/pmd.html")
- }
- }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
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.java b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.java
deleted file mode 100644
index 80caf0010..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.SharedPreferences;
-import android.preference.PreferenceManager;
-import android.support.test.espresso.Espresso;
-import android.support.test.espresso.action.ViewActions;
-import android.support.test.espresso.assertion.ViewAssertions;
-import android.support.test.espresso.matcher.PreferenceMatchers;
-import android.support.test.espresso.matcher.ViewMatchers;
-import android.support.test.filters.LargeTest;
-import android.support.test.rule.ActivityTestRule;
-import android.support.test.runner.AndroidJUnit4;
-
-import org.hamcrest.Matchers;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.Map;
-
-import fr.free.nrw.commons.settings.SettingsActivity;
-
-@LargeTest
-@RunWith(AndroidJUnit4.class)
-public class SettingsActivityTest {
- private SharedPreferences prefs;
- private Map prefValues;
-
- @Rule
- public ActivityTestRule activityRule =
- new ActivityTestRule(SettingsActivity.class,
- false /* Initial touch mode */, true /* launch activity */) {
-
- @Override
- protected void afterActivityLaunched() {
- // save preferences
- prefs = PreferenceManager.getDefaultSharedPreferences(this.getActivity());
- prefValues = prefs.getAll();
- }
-
- @Override
- protected void afterActivityFinished() {
- // restore preferences
- SharedPreferences.Editor editor = prefs.edit();
- for (Map.Entry entry: prefValues.entrySet()) {
- String key = entry.getKey();
- Object val = entry.getValue();
- if (val instanceof String) {
- editor.putString(key, (String)val);
- } else if (val instanceof Boolean) {
- editor.putBoolean(key, (Boolean)val);
- } else if (val instanceof Integer) {
- editor.putInt(key, (Integer)val);
- } else {
- throw new RuntimeException("type not implemented: " + entry);
- }
- }
- editor.apply();
- }
- };
-
- @Test
- public void oneLicenseIsChecked() {
- // click "License" (the first item)
- Espresso.onData(PreferenceMatchers.withKey("defaultLicense"))
- .inAdapterView(ViewMatchers.withId(android.R.id.list))
- .atPosition(0)
- .perform(ViewActions.click());
-
- // test the selected item
- Espresso.onView(ViewMatchers.isChecked())
- .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
- }
-
- @Test
- public void afterClickingCcby4ItWillStay() {
- // click "License" (the first item)
- Espresso.onData(PreferenceMatchers.withKey("defaultLicense"))
- .inAdapterView(ViewMatchers.withId(android.R.id.list))
- .atPosition(0)
- .perform(ViewActions.click());
-
- // click "Attribution 4.0"
- Espresso.onView(
- ViewMatchers.withText(R.string.license_name_cc_by_four)
- ).perform(ViewActions.click());
-
- // click "License" (the first item)
- Espresso.onData(PreferenceMatchers.withKey("defaultLicense"))
- .inAdapterView(ViewMatchers.withId(android.R.id.list))
- .atPosition(0)
- .perform(ViewActions.click());
-
- // test the value remains "Attribution 4.0"
- Espresso.onView(ViewMatchers.isChecked())
- .check(ViewAssertions.matches(
- ViewMatchers.withText(R.string.license_name_cc_by_four)
- ));
- }
-}
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/upload/FileUtilsTest.java b/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java
deleted file mode 100644
index d2db4614f..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package fr.free.nrw.commons.upload;
-
-import android.net.Uri;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import fr.free.nrw.commons.BuildConfig;
-
-import static org.hamcrest.CoreMatchers.is;
-import static org.junit.Assert.assertThat;
-
-@RunWith(AndroidJUnit4.class)
-public class FileUtilsTest {
- @Test
- public void isSelfOwned() throws Exception {
- Uri uri = Uri.parse("content://" + BuildConfig.APPLICATION_ID + ".provider/document/1");
- boolean selfOwned = FileUtils.isSelfOwned(InstrumentationRegistry.getTargetContext(), uri);
- assertThat(selfOwned, is(true));
- }
-
- @Test
- public void isNotSelfOwned() throws Exception {
- Uri uri = Uri.parse("content://com.android.providers.media.documents/document/1");
- boolean selfOwned = FileUtils.isSelfOwned(InstrumentationRegistry.getTargetContext(), uri);
- assertThat(selfOwned, is(false));
- }
-}
\ No newline at end of file
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/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/drawable-hdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-hdpi/ic_launcher.png
deleted file mode 100644
index 46c0a4202..000000000
Binary files a/app/src/betaDebug/res/drawable-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png
deleted file mode 100644
index 2e5499676..000000000
Binary files a/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png
deleted file mode 100644
index 0f0c702ed..000000000
Binary files a/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..90c044ccd
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..f826d5544
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..9b273c43f
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..b09b8d252
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..5002ec69d
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..9aa2611ba
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d7b349b4d
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..9297963fd
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..59b088069
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d473d0aed
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..aeb616311
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..0b7797049
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e88874931
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..fa5017d72
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..00a9e4bd5
Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e80e2a84f..17917666d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,210 +1,259 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
-
+
+
+
+
-
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
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/assets/mapstyle.json b/app/src/main/assets/mapstyle.json
deleted file mode 100644
index d4291cbdc..000000000
--- a/app/src/main/assets/mapstyle.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "version": 8,
- "sources": {
- "wikimedia-osm": {
- "type": "raster",
- "tiles": [
- "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"
- ],
- "tileSize": 128
- }
- },
- "layers": [
- {
- "id": "background",
- "type": "background",
- "paint": {
- "background-color": "#606060"
- }
- },
- {
- "id": "osm",
- "type": "raster",
- "source": "wikimedia-osm"
- }
- ]
-}
\ No newline at end of file
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 c8941dcd8..000000000
--- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
+++ /dev/null
@@ -1,177 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.annotation.SuppressLint;
-import android.app.AlertDialog;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.Html;
-import android.text.SpannableString;
-import android.text.style.UnderlineSpan;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.ArrayAdapter;
-import android.widget.LinearLayout;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.ui.widget.HtmlTextView;
-
-/**
- * Represents about screen of this app
- */
-public class AboutActivity extends NavigationBaseActivity {
- @BindView(R.id.about_version) TextView versionText;
- @BindView(R.id.about_license) HtmlTextView aboutLicenseText;
- @BindView(R.id.about_faq) TextView faqText;
-
- String language[] = { "Kazakh", "Afrikaans", "Arabic", "Bengali", "Asturianu", "azərbaycanca", "Bikol Central",
- "Bulgarain", "বাংলা", "Bosanski", "Brezhoneg","català","کوردی", " čeština", " kaszëbsczi", "Cymraeg", "dansk", "Deutsch"
- ,"Zazaki", "डोटेली","Ελληνικά","euskara","español","فارسی","suomi", "français" ,"Nordfriisk", "galego", "Hawaiʻi"
- ,"हिन्दी","Hunsrik","עברית","hornjoserbsce","magyar","interlingua","Bahasa Indonesia", "íslenska","Italian","japanese",
- "Basa Jawa", "ქართული", " ភាសាខ្មែរ","ಕನ್ನಡ", "한국어","къарачай-малкъар","Кыргызча", "latina", "Lëtzebuergesch", "lietuvių",
- "latviešu", "Malagasy", "македонски"," മലയാളം","монгол","मराठी","Bahasa Melayu","Malti", "नेपाली", "norsk bokmål",
- " Nederlands","occitan","ଓଡ଼ିଆ","ਪੰਜਾਬੀ","polsk","Piemontèis","پښتو","português","română","русский"," سنڌي", " සිංහල",
- "slovenčina"," سرائیکی", "svenska", "தமிழ்", "ತುಳು"," తెలుగు"," ไทย", "Türkçe","українська", "اردو", "Tiếng Việt",
- " მარგალური","ייִדיש",};
-
- /**
- * This method helps in the creation About screen
- *
- * @param savedInstanceState Data bundle
- */
- @Override
- @SuppressLint("StringFormatInvalid")
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_about);
-
- ButterKnife.bind(this);
- String aboutText = getString(R.string.about_license);
- aboutLicenseText.setHtmlText(aboutText);
- SpannableString content = new SpannableString(getString(R.string.about_faq));
- content.setSpan(new UnderlineSpan(), 0, content.length(), 0);
- faqText.setText(content);
- versionText.setText(BuildConfig.VERSION_NAME);
- TextView rate_us = findViewById(R.id.about_rate_us);
- TextView privacy_policy = findViewById(R.id.about_privacy_policy);
- TextView translate = findViewById(R.id.about_translate);
- TextView credits = findViewById(R.id.about_credits);
- TextView faq = findViewById(R.id.about_faq);
-
- rate_us.setText(Html.fromHtml(getString(R.string.about_rate_us)));
- privacy_policy.setText(Html.fromHtml(getString(R.string.about_privacy_policy)));
- translate.setText(Html.fromHtml(getString(R.string.about_translate)));
- credits.setText(Html.fromHtml(getString(R.string.about_credits)));
- faq.setText(Html.fromHtml(getString(R.string.about_faq)));
-
- initDrawer();
- }
-
- @OnClick(R.id.facebook_launch_icon)
- public void launchFacebook(View view) {
- Intent intent;
- try {
- intent = new Intent(Intent.ACTION_VIEW, Uri.parse("fb://page/" + "1921335171459985"));
- intent.setPackage("com.facebook.katana");
- startActivity(intent);
- } catch (Exception e) {
- Utils.handleWebUrl(this,Uri.parse("https://www.facebook.com/" + "1921335171459985"));
- }
- }
-
- @OnClick(R.id.github_launch_icon)
- public void launchGithub(View view) {
- Utils.handleWebUrl(this,Uri.parse("https://github.com/commons-app/apps-android-commons\\"));
- }
-
- @OnClick(R.id.website_launch_icon)
- public void launchWebsite(View view) {
- Utils.handleWebUrl(this,Uri.parse("https://commons-app.github.io/\\"));
- }
-
- @OnClick(R.id.about_rate_us)
- public void launchRatings(View view){
- Utils.rateApp(this);
- }
-
- @OnClick(R.id.about_credits)
- public void launchCredits(View view) {
- Utils.handleWebUrl(this,Uri.parse("https://github.com/commons-app/apps-android-commons/blob/master/CREDITS/\\"));
- }
-
- @OnClick(R.id.about_privacy_policy)
- public void launchPrivacyPolicy(View view) {
- Utils.handleWebUrl(this,Uri.parse("https://github.com/commons-app/apps-android-commons/wiki/Privacy-policy\\"));
- }
-
-
- @OnClick(R.id.about_faq)
- public void launchFrequentlyAskedQuesions(View view) {
- Utils.handleWebUrl(this,Uri.parse("https://github.com/commons-app/apps-android-commons/wiki/Frequently-Asked-Questions\\"));
- }
-
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- MenuInflater inflater = getMenuInflater();
- inflater.inflate(R.menu.menu_about, menu);
- return super.onCreateOptionsMenu(menu);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case R.id.share_app_icon:
- String shareText = "Upload photos to Wikimedia Commons on your phone\nDownload the Commons app: http://play.google.com/store/apps/details?id=fr.free.nrw.commons";
- Intent sendIntent = new Intent();
- sendIntent.setAction(Intent.ACTION_SEND);
- sendIntent.putExtra(Intent.EXTRA_TEXT, shareText);
- sendIntent.setType("text/plain");
- startActivity(Intent.createChooser(sendIntent, "Share app via..."));
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-
- @OnClick(R.id.about_translate)
- public void launchTranslate(View view) {
- final ArrayAdapter languageAdapter = new ArrayAdapter(AboutActivity.this,
- android.R.layout.simple_spinner_item, language);
- final Spinner spinner = new Spinner(AboutActivity.this);
- spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
- spinner.setAdapter(languageAdapter);
- spinner.setGravity(17);
-
- AlertDialog.Builder builder = new AlertDialog.Builder(AboutActivity.this);
- builder.setView(spinner);
- builder.setTitle(R.string.about_translate_title)
- .setMessage(R.string.about_translate_message)
- .setPositiveButton(R.string.about_translate_proceed, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- String languageSelected = spinner.getSelectedItem().toString();
- TokensTranslations tokensTranslations = new TokensTranslations();
- tokensTranslations.initailize();
- String token = tokensTranslations.getTranslationToken(languageSelected);
- Utils.handleWebUrl(AboutActivity.this,Uri.parse("https://translatewiki.net/w/i.php?title=Special:Translate&language="+token+"&group=commons-android-strings&filter=%21translated&action=translate ?"));
- }
- });
- builder.setNegativeButton(R.string.about_translate_cancel, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- finish();
- }
- });
- builder.create().show();
-
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
new file mode 100644
index 000000000..865ad3ddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
@@ -0,0 +1,207 @@
+package fr.free.nrw.commons
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.Intent.ACTION_VIEW
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.LinearLayout
+import android.widget.Spinner
+import fr.free.nrw.commons.CommonsApplication.Companion.instance
+import fr.free.nrw.commons.databinding.ActivityAboutBinding
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
+import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
+import java.util.Collections
+import androidx.core.net.toUri
+import fr.free.nrw.commons.utils.applyEdgeToEdgeTopInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import fr.free.nrw.commons.utils.setUnderlinedText
+
+/**
+ * Represents about screen of this app
+ */
+class AboutActivity : BaseActivity() {
+ /*
+ This View Binding class is auto-generated for each xml file. The format is usually the name
+ of the file with PascalCasing (The underscore characters will be ignored).
+ More information is available at https://developer.android.com/topic/libraries/view-binding
+ */
+ private var binding: ActivityAboutBinding? = null
+
+ /**
+ * This method helps in the creation About screen
+ *
+ * @param savedInstanceState Data bundle
+ */
+ @SuppressLint("StringFormatInvalid") //TODO:
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ /*
+ Instead of just setting the view with the xml file. We need to use View Binding class.
+ */
+ binding = ActivityAboutBinding.inflate(layoutInflater)
+ val view: View = binding!!.root
+ applyEdgeToEdgeTopInsets(binding!!.toolbarLayout)
+ setContentView(view)
+
+ setSupportActionBar(binding!!.toolbarBinding.toolbar)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+ val aboutText = getString(R.string.about_license)
+ /*
+ We can then access all the views by just using the id names like this.
+ camelCasing is used with underscore characters being ignored.
+ */
+ binding!!.aboutLicense.setHtmlText(aboutText)
+
+ @SuppressLint("StringFormatMatches") // TODO:
+ val improveText =
+ String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL)
+ binding!!.aboutImprove.setHtmlText(improveText)
+ binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha()
+
+ binding!!.aboutFaq.setUnderlinedText(R.string.about_faq)
+ binding!!.aboutRateUs.setUnderlinedText(R.string.about_rate_us)
+ binding!!.aboutUserGuide.setUnderlinedText(R.string.user_guide)
+ binding!!.aboutPrivacyPolicy.setUnderlinedText(R.string.about_privacy_policy)
+ binding!!.aboutTranslate.setUnderlinedText(R.string.about_translate)
+ binding!!.aboutCredits.setUnderlinedText(R.string.about_credits)
+
+ /*
+ To set listeners, we can create a separate method and use lambda syntax.
+ */
+ binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook)
+ binding!!.githubLaunchIcon.setOnClickListener(::launchGithub)
+ binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite)
+ binding!!.aboutRateUs.setOnClickListener(::launchRatings)
+ binding!!.aboutCredits.setOnClickListener(::launchCredits)
+ binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy)
+ binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide)
+ binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions)
+ binding!!.aboutTranslate.setOnClickListener(::launchTranslate)
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ onBackPressed()
+ return true
+ }
+
+ fun launchFacebook(view: View?) {
+ val intent: Intent
+ try {
+ intent = Intent(ACTION_VIEW, Urls.FACEBOOK_APP_URL.toUri())
+ intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME)
+ startActivity(intent)
+ } catch (e: Exception) {
+ handleWebUrl(this, Urls.FACEBOOK_WEB_URL.toUri())
+ }
+ }
+
+ fun launchGithub(view: View?) {
+ val intent: Intent
+ try {
+ intent = Intent(ACTION_VIEW, Urls.GITHUB_REPO_URL.toUri())
+ intent.setPackage(Urls.GITHUB_PACKAGE_NAME)
+ startActivity(intent)
+ } catch (e: Exception) {
+ handleWebUrl(this, Urls.GITHUB_REPO_URL.toUri())
+ }
+ }
+
+ fun launchWebsite(view: View?) {
+ handleWebUrl(this, Urls.WEBSITE_URL.toUri())
+ }
+
+ fun launchRatings(view: View?) {
+ try {
+ startActivity(
+ Intent(
+ ACTION_VIEW,
+ (Urls.PLAY_STORE_PREFIX + packageName).toUri()
+ )
+ )
+ } catch (_: ActivityNotFoundException) {
+ handleWebUrl(this, (Urls.PLAY_STORE_URL_PREFIX + packageName).toUri())
+ }
+ }
+
+ fun launchCredits(view: View?) {
+ handleWebUrl(this, Urls.CREDITS_URL.toUri())
+ }
+
+ fun launchUserGuide(view: View?) {
+ handleWebUrl(this, Urls.USER_GUIDE_URL.toUri())
+ }
+
+ fun launchPrivacyPolicy(view: View?) {
+ handleWebUrl(this, BuildConfig.PRIVACY_POLICY_URL.toUri())
+ }
+
+ fun launchFrequentlyAskedQuesions(view: View?) {
+ handleWebUrl(this, Urls.FAQ_URL.toUri())
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ val inflater = menuInflater
+ inflater.inflate(R.menu.menu_about, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.share_app_icon -> {
+ val shareText = String.format(
+ getString(R.string.share_text),
+ Urls.PLAY_STORE_URL_PREFIX + this.packageName
+ )
+ val sendIntent = Intent()
+ sendIntent.setAction(Intent.ACTION_SEND)
+ sendIntent.putExtra(Intent.EXTRA_TEXT, shareText)
+ sendIntent.setType("text/plain")
+ startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via)))
+ return true
+ }
+
+ else -> return super.onOptionsItemSelected(item)
+ }
+ }
+
+ fun launchTranslate(view: View?) {
+ val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames()
+ Collections.sort(sortedLocalizedNamesRef)
+ val languageAdapter = ArrayAdapter(
+ this@AboutActivity,
+ android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef
+ )
+ val spinner = Spinner(this@AboutActivity)
+ spinner.layoutParams =
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ spinner.adapter = languageAdapter
+ spinner.gravity = 17
+ spinner.setPadding(50, 0, 0, 0)
+
+ val positiveButtonRunnable = Runnable {
+ val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition]
+ handleWebUrl(this@AboutActivity, (Urls.TRANSLATE_WIKI_URL + langCode).toUri())
+ }
+ showAlertDialog(
+ this,
+ getString(R.string.about_translate_title),
+ getString(R.string.about_translate_message),
+ getString(R.string.about_translate_proceed),
+ getString(R.string.about_translate_cancel),
+ positiveButtonRunnable,
+ {},
+ spinner
+ )
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
new file mode 100644
index 000000000..28b01d603
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Place
+
+class BaseMarker {
+ private var _position: LatLng = LatLng(0.0, 0.0, 0f)
+ private var _title: String = ""
+ private var _place: Place = Place()
+ private var _icon: Bitmap? = null
+
+ var position: LatLng
+ get() = _position
+ set(value) {
+ _position = value
+ }
+ var title: String
+ get() = _title
+ set(value) {
+ _title = value
+ }
+
+ var place: Place
+ get() = _place
+ set(value) {
+ _place = value
+ }
+ var icon: Bitmap?
+ get() = _icon
+ set(value) {
+ _icon = value
+ }
+
+ constructor() {
+ }
+
+ fun fromResource(
+ context: Context,
+ drawableResId: Int,
+ ) {
+ val drawable: Drawable = context.resources.getDrawable(drawableResId)
+ icon =
+ if (drawable is BitmapDrawable) {
+ drawable.bitmap
+ } else {
+ val bitmap =
+ Bitmap.createBitmap(
+ drawable.intrinsicWidth,
+ drawable.intrinsicHeight,
+ Bitmap.Config.ARGB_8888,
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ bitmap
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BasePresenter.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 5fcab1d0b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
+++ /dev/null
@@ -1,182 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.database.sqlite.SQLiteDatabase;
-import android.support.multidex.MultiDexApplication;
-
-import com.facebook.drawee.backends.pipeline.Fresco;
-import com.facebook.imagepipeline.core.ImagePipelineConfig;
-import com.facebook.stetho.Stetho;
-import com.squareup.leakcanary.LeakCanary;
-import com.squareup.leakcanary.RefWatcher;
-import com.tspoon.traceur.Traceur;
-import com.tspoon.traceur.TraceurConfig;
-
-import org.acra.ACRA;
-import org.acra.ReportingInteractionMode;
-import org.acra.annotation.ReportsCrashes;
-
-import java.io.File;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.category.CategoryDao;
-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.modifications.ModifierSequenceDao;
-import fr.free.nrw.commons.upload.FileUtils;
-import fr.free.nrw.commons.utils.ContributionUtils;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-// 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 MultiDexApplication {
-
- @Inject SessionManager sessionManager;
- @Inject DBOpenHelper dbOpenHelper;
-
- @Inject @Named("default_preferences") SharedPreferences defaultPrefs;
- @Inject @Named("application_preferences") SharedPreferences applicationPrefs;
- @Inject @Named("prefs") SharedPreferences otherPrefs;
-
- public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using [[COM:MOA|Commons Mobile App]]";
-
- public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
-
- public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App (%s) Feedback";
-
- public static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com";
-
- public static final String LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs";
-
- private RefWatcher refWatcher;
-
-
- /**
- * Used to declare and initialize various components and dependencies
- */
- @Override
- public void onCreate() {
- super.onCreate();
- if (BuildConfig.DEBUG) {
- //FIXME: Traceur should be disabled for release builds until error fixed
- //See https://github.com/commons-app/apps-android-commons/issues/1877
- Traceur.enableLogging();
- }
-
- ApplicationlessInjection
- .getInstance(this)
- .getCommonsApplicationComponent()
- .inject(this);
-// Set DownsampleEnabled to True to downsample the image in case it's heavy
- ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
- .setDownsampleEnabled(true)
- .build();
- Fresco.initialize(this,config);
- if (setupLeakCanary() == RefWatcher.DISABLED) {
- return;
- }
- // Empty temp directory in case some temp files are created and never removed.
- ContributionUtils.emptyTemporaryDirectory();
-
- Timber.plant(new Timber.DebugTree());
-
- if (!BuildConfig.DEBUG) {
- ACRA.init(this);
- } else {
- Stetho.initializeWithDefaults(this);
- }
-
- // Fire progress callbacks for every 3% of uploaded content
- System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
- }
-
-
- /**
- * Helps in setting up LeakCanary library
- * @return instance of LeakCanary
- */
- protected RefWatcher setupLeakCanary() {
- if (LeakCanary.isInAnalyzerProcess(this)) {
- return RefWatcher.DISABLED;
- }
- return LeakCanary.install(this);
- }
-
- /**
- * Provides a way to get member refWatcher
- *
- * @param context Application context
- * @return application member refWatcher
- */
- public static RefWatcher getRefWatcher(Context context) {
- CommonsApplication application = (CommonsApplication) context.getApplicationContext();
- return application.refWatcher;
- }
-
- /**
- * clears data of current application
- * @param context Application context
- * @param logoutListener Implementation of interface LogoutListener
- */
- @SuppressLint("CheckResult")
- public void clearApplicationData(Context context, LogoutListener logoutListener) {
- File cacheDirectory = context.getCacheDir();
- File applicationDirectory = new File(cacheDirectory.getParent());
- if (applicationDirectory.exists()) {
- String[] fileNames = applicationDirectory.list();
- for (String fileName : fileNames) {
- if (!fileName.equals("lib")) {
- FileUtils.deleteFile(new File(applicationDirectory, fileName));
- }
- }
- }
-
- sessionManager.logout()
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(() -> {
- Timber.d("All accounts have been removed");
- //TODO: fix preference manager
- defaultPrefs.edit().clear().apply();
- applicationPrefs.edit().clear().apply();
- applicationPrefs.edit().putBoolean("firstrun", false).apply();
- otherPrefs.edit().clear().apply();
- updateAllDatabases();
- logoutListener.onLogoutComplete();
- });
- }
-
- /**
- * Deletes all tables and re-creates them.
- */
- private void updateAllDatabases() {
- dbOpenHelper.getReadableDatabase().close();
- SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
-
- ModifierSequenceDao.Table.onDelete(db);
- CategoryDao.Table.onDelete(db);
- ContributionDao.Table.onDelete(db);
- }
-
- /**
- * Interface used to get log-out events
- */
- public interface LogoutListener {
- void onLogoutComplete();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
new file mode 100644
index 000000000..89fdaa055
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
@@ -0,0 +1,417 @@
+package fr.free.nrw.commons
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.database.sqlite.SQLiteException
+import android.os.Build
+import android.os.Process
+import android.util.Log
+import androidx.multidex.MultiDexApplication
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.imagepipeline.core.ImagePipelineConfig
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable
+import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable
+import fr.free.nrw.commons.category.CategoryDao
+import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
+import fr.free.nrw.commons.concurrency.ThreadPoolService
+import fr.free.nrw.commons.contributions.ContributionDao
+import fr.free.nrw.commons.data.DBOpenHelper
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.language.AppLanguageLookUpTable
+import fr.free.nrw.commons.logging.FileLoggingTree
+import fr.free.nrw.commons.logging.LogUtils
+import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher
+import fr.free.nrw.commons.settings.Prefs
+import fr.free.nrw.commons.upload.FileUtils
+import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
+import io.reactivex.Completable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.internal.functions.Functions
+import io.reactivex.plugins.RxJavaPlugins
+import io.reactivex.schedulers.Schedulers
+import org.acra.ACRA.init
+import org.acra.ReportField
+import org.acra.annotation.AcraCore
+import org.acra.annotation.AcraDialog
+import org.acra.annotation.AcraMailSender
+import org.acra.data.StringFormat
+import timber.log.Timber
+import timber.log.Timber.DebugTree
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Named
+
+@AcraCore(
+ buildConfigClass = BuildConfig::class,
+ resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
+ reportFormat = StringFormat.KEY_VALUE_LIST,
+ reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE]
+)
+
+@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false)
+
+@AcraDialog(
+ resTheme = R.style.Theme_AppCompat_Dialog,
+ resText = R.string.crash_dialog_text,
+ resTitle = R.string.crash_dialog_title,
+ resCommentPrompt = R.string.crash_dialog_comment_prompt
+)
+
+class CommonsApplication : MultiDexApplication() {
+
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ lateinit var dbOpenHelper: DBOpenHelper
+
+ @Inject
+ @field:Named("default_preferences")
+ lateinit var defaultPrefs: JsonKvStore
+
+ @Inject
+ lateinit var cookieJar: CommonsCookieJar
+
+ @Inject
+ lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher
+
+ var languageLookUpTable: AppLanguageLookUpTable? = null
+ private set
+
+ @Inject
+ lateinit var contributionDao: ContributionDao
+
+ /**
+ * Used to declare and initialize various components and dependencies
+ */
+ override fun onCreate() {
+ super.onCreate()
+
+ instance = this
+ init(this)
+
+ ApplicationlessInjection
+ .getInstance(this)
+ .commonsApplicationComponent
+ .inject(this)
+
+ initTimber()
+
+ if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
+ var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS)
+ if (null == defaultExifTagsSet) {
+ defaultExifTagsSet = HashSet()
+ }
+ defaultExifTagsSet.add(getString(R.string.exif_tag_location))
+ defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet)
+ }
+
+ // Set DownsampleEnabled to True to downsample the image in case it's heavy
+ val config = ImagePipelineConfig.newBuilder(this)
+ .setNetworkFetcher(customOkHttpNetworkFetcher)
+ .setDownsampleEnabled(true)
+ .build()
+ try {
+ Fresco.initialize(this, config)
+ } catch (e: Exception) {
+ Timber.e(e)
+ // TODO: Remove when we're able to initialize Fresco in test builds.
+ }
+
+ createNotificationChannel(this)
+
+ languageLookUpTable = AppLanguageLookUpTable(this)
+
+ // This handler will catch exceptions thrown from Observables after they are disposed,
+ // or from Observables that are (deliberately or not) missing an onError handler.
+ RxJavaPlugins.setErrorHandler(Functions.emptyConsumer())
+
+ // Fire progress callbacks for every 3% of uploaded content
+ System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
+ }
+
+ /**
+ * Plants debug and file logging tree. Timber lets you plant your own logging trees.
+ */
+ private fun initTimber() {
+ val isBeta = isBetaFlavour
+ val logFileName =
+ if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs"
+ val logDirectory = LogUtils.getLogDirectory()
+ //Delete stale logs if they have exceeded the specified size
+ deleteStaleLogs(logFileName, logDirectory)
+
+ val tree = FileLoggingTree(
+ Log.VERBOSE,
+ logFileName,
+ logDirectory,
+ 1000,
+ fileLoggingThreadPool
+ )
+
+ Timber.plant(tree)
+ Timber.plant(DebugTree())
+ }
+
+ /**
+ * Deletes the logs zip file at the specified directory and file locations specified in the
+ * params
+ *
+ * @param logFileName
+ * @param logDirectory
+ */
+ private fun deleteStaleLogs(logFileName: String, logDirectory: String) {
+ try {
+ val file = File("$logDirectory/zip/$logFileName.zip")
+ if (file.exists() && file.totalSpace > 1000000) { // In Kbs
+ file.delete()
+ }
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+ }
+
+ private val fileLoggingThreadPool: ThreadPoolService
+ get() = ThreadPoolService.Builder("file-logging-thread")
+ .setPriority(Process.THREAD_PRIORITY_LOWEST)
+ .setPoolSize(1)
+ .setExceptionHandler(BackgroundPoolExceptionHandler())
+ .build()
+
+ val userAgent: String
+ get() = ("Commons/" + this.getVersionNameWithSha()
+ + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE)
+
+ /**
+ * clears data of current application
+ *
+ * @param context Application context
+ * @param logoutListener Implementation of interface LogoutListener
+ */
+ @SuppressLint("CheckResult")
+ fun clearApplicationData(context: Context, logoutListener: LogoutListener) {
+ val cacheDirectory = context.cacheDir
+ val applicationDirectory = File(cacheDirectory.parent)
+ if (applicationDirectory.exists()) {
+ val fileNames = applicationDirectory.list()
+ for (fileName in fileNames) {
+ if (fileName != "lib") {
+ FileUtils.deleteFile(File(applicationDirectory, fileName))
+ }
+ }
+ }
+
+ sessionManager.logout()
+ .andThen(Completable.fromAction { cookieJar.clear() })
+ .andThen(Completable.fromAction {
+ Timber.d("All accounts have been removed")
+ clearImageCache()
+ //TODO: fix preference manager
+ defaultPrefs.clearAll()
+ defaultPrefs.putBoolean("firstrun", false)
+ updateAllDatabases()
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) })
+ }
+
+ /**
+ * Clear all images cache held by Fresco
+ */
+ private fun clearImageCache() {
+ val imagePipeline = Fresco.getImagePipeline()
+ imagePipeline.clearCaches()
+ }
+
+ /**
+ * Deletes all tables and re-creates them.
+ */
+ private fun updateAllDatabases() {
+ dbOpenHelper.readableDatabase.close()
+ val db = dbOpenHelper.writableDatabase
+
+ CategoryDao.Table.onDelete(db)
+ dbOpenHelper.deleteTable(
+ db,
+ DBOpenHelper.CONTRIBUTIONS_TABLE
+ ) //Delete the contributions table in the existing db on older versions
+
+ dbOpenHelper.deleteTable(
+ db,
+ DBOpenHelper.BOOKMARKS_LOCATIONS
+ )
+
+ try {
+ contributionDao.deleteAll()
+ } catch (e: SQLiteException) {
+ Timber.e(e)
+ }
+ BookmarksTable.onDelete(db)
+ BookmarkItemsTable.onDelete(db)
+ }
+
+
+ /**
+ * Interface used to get log-out events
+ */
+ interface LogoutListener {
+ fun onLogoutComplete()
+ }
+
+ /**
+ * This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
+ * with relevant intent parameters. It does not perform the actual logout operation.
+ */
+ open class BaseLogoutListener : LogoutListener {
+ var ctx: Context
+ var loginMessage: String? = null
+ var userName: String? = null
+
+ /**
+ * Constructor for BaseLogoutListener.
+ *
+ * @param ctx Application context
+ */
+ constructor(ctx: Context) {
+ this.ctx = ctx
+ }
+
+ /**
+ * Constructor for BaseLogoutListener
+ *
+ * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+ * @param loginMessage Message to be displayed on the login page
+ * @param loginUsername Username to be pre-filled on the login page
+ */
+ constructor(
+ ctx: Context, loginMessage: String?,
+ loginUsername: String?
+ ) {
+ this.ctx = ctx
+ this.loginMessage = loginMessage
+ this.userName = loginUsername
+ }
+
+ override fun onLogoutComplete() {
+ Timber.d("Logout complete callback received.")
+ val loginIntent = Intent(ctx, LoginActivity::class.java)
+ loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ if (loginMessage != null) {
+ loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage)
+ }
+ if (userName != null) {
+ loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName)
+ }
+
+ ctx.startActivity(loginIntent)
+ }
+ }
+
+ /**
+ * This class is an extension of BaseLogoutListener, providing additional functionality or customization
+ * for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
+ */
+ class ActivityLogoutListener : BaseLogoutListener {
+ var activity: Activity
+
+
+ /**
+ * Constructor for ActivityLogoutListener.
+ *
+ * @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
+ * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+ */
+ constructor(activity: Activity, ctx: Context) : super(ctx) {
+ this.activity = activity
+ }
+
+ /**
+ * Constructor for ActivityLogoutListener with additional parameters for the login screen.
+ *
+ * @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
+ * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
+ * @param loginMessage Message to be displayed on the login page after logout.
+ * @param loginUsername Username to be pre-filled on the login page after logout.
+ */
+ constructor(
+ activity: Activity, ctx: Context?,
+ loginMessage: String?, loginUsername: String?
+ ) : super(activity, loginMessage, loginUsername) {
+ this.activity = activity
+ }
+
+ override fun onLogoutComplete() {
+ super.onLogoutComplete()
+ activity.finish()
+ }
+ }
+
+ companion object {
+
+ const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage"
+ const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername"
+
+ const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled"
+
+ /**
+ * Constants begin
+ */
+ const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001
+
+ const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]"
+
+ const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com"
+
+ const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback"
+
+ const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com"
+
+ const val REPORT_EMAIL_SUBJECT: String = "Report a violation"
+
+ const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll"
+
+ const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --"
+
+ /**
+ * Constants End
+ */
+
+ @JvmStatic
+ lateinit var instance: CommonsApplication
+ private set
+
+ @JvmField
+ var isPaused: Boolean = false
+
+ @JvmStatic
+ fun createNotificationChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = context
+ .getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ var channel = manager
+ .getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL)
+ if (channel == null) {
+ channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID_ALL,
+ context.getString(R.string.notifications_channel_name_all),
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/fr/free/nrw/commons/HandlerService.java b/app/src/main/java/fr/free/nrw/commons/HandlerService.java
deleted file mode 100644
index e5e1b3b1b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/HandlerService.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Intent;
-import android.os.Binder;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Message;
-
-import fr.free.nrw.commons.di.CommonsDaggerService;
-
-public abstract class HandlerService extends CommonsDaggerService {
- private volatile Looper threadLooper;
- private volatile ServiceHandler threadHandler;
- private String serviceName;
-
- private final class ServiceHandler extends Handler {
- public ServiceHandler(Looper looper) {
- super(looper);
- }
-
- @Override
- public void handleMessage(Message msg) {
- //FIXME: Google Photos bug
- handle(msg.what, (T)msg.obj);
- stopSelf(msg.arg1);
- }
- }
-
- @Override
- public void onDestroy() {
- threadLooper.quit();
- super.onDestroy();
- }
-
- public class HandlerServiceLocalBinder extends Binder {
- public HandlerService getService() {
- return HandlerService.this;
- }
- }
-
- private final IBinder localBinder = new HandlerServiceLocalBinder();
- @Override
- public IBinder onBind(Intent intent) {
- return localBinder;
- }
-
- protected HandlerService(String serviceName) {
- this.serviceName = serviceName;
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- HandlerThread thread = new HandlerThread(serviceName);
- thread.start();
-
- threadLooper = thread.getLooper();
- threadHandler = new ServiceHandler(threadLooper);
- }
-
- private void postMessage(int type, T t) {
- Message msg = threadHandler.obtainMessage(type);
- msg.obj = t;
- threadHandler.sendMessage(msg);
- }
-
- public void queue(int what, T t) {
- postMessage(what, t);
- }
-
- protected abstract void handle(int what, T t);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/License.java b/app/src/main/java/fr/free/nrw/commons/License.java
deleted file mode 100644
index db893de16..000000000
--- a/app/src/main/java/fr/free/nrw/commons/License.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.support.annotation.Nullable;
-
-/**
- * represents Licence object
- */
-public class License {
- private String key;
- private String template;
- private String url;
- private String name;
-
- /**
- * Constructs a new instance of License.
- *
- * @param key license key
- * @param template license template
- * @param url license URL
- * @param name licence name
- *
- * @throws RuntimeException if License.key or Licence.template is null
- */
- public License(String key, String template, String url, String name) {
- if (key == null) {
- throw new RuntimeException("License.key must not be null");
- }
- if (template == null) {
- throw new RuntimeException("License.template must not be null");
- }
- this.key = key;
- this.template = template;
- this.url = url;
- this.name = name;
- }
-
- /**
- * Gets the license key.
- * @return license key as a String.
- */
- public String getKey() {
- return key;
- }
-
- /**
- * Gets the license template.
- * @return license template as a String.
- */
- public String getTemplate() {
- return template;
- }
-
- public String getName() {
- if (name == null) {
- // hack
- return getKey();
- } else {
- return name;
- }
- }
-
- /**
- * Gets the license URL
- *
- * @param language license language
- * @return URL
- */
- public @Nullable String getUrl(String language) {
- if (url == null) {
- return null;
- } else {
- return url.replace("$lang", language);
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/LicenseList.java b/app/src/main/java/fr/free/nrw/commons/LicenseList.java
deleted file mode 100644
index d08e314cc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LicenseList.java
+++ /dev/null
@@ -1,127 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.app.Activity;
-import android.content.res.Resources;
-import android.support.annotation.Nullable;
-
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
-
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-
-/**
- * Represents a list of Licenses
- */
-public class LicenseList {
- private Map licenses = new HashMap<>();
- private Resources res;
-
- /**
- * Constructs new instance of LicenceList
- *
- * @param activity License activity
- */
- public LicenseList(Activity activity) {
- res = activity.getResources();
- XmlPullParser parser = res.getXml(R.xml.wikimedia_licenses);
- String namespace = "https://www.mediawiki.org/wiki/Extension:UploadWizard/xmlns/licenses";
- while (xmlFastForward(parser, namespace, "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);
- }
- }
-
- /**
- * Gets a collection of licenses
- * @return License values
- */
- public Collection values() {
- return licenses.values();
- }
-
- /**
- * Gets license
- * @param key License key
- * @return License that matches key
- */
- public License get(String key) {
- return licenses.get(key);
- }
-
- /**
- * Creates a license from template
- * @param template License template
- * @return null
- */
- @Nullable
- License licenseForTemplate(String template) {
- String ucTemplate = new PageTitle(template).getDisplayText();
- for (License license : values()) {
- if (ucTemplate.equals(new PageTitle(license.getTemplate()).getDisplayText())) {
- return license;
- }
- }
- return null;
- }
-
- /**
- * Gets template name id
- * @param template License template
- * @return name id of template
- */
- private 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(".", "_");
- }
-
- /**
- * Gets name of given template
- * @param template License template
- * @return name of template
- */
- private String nameForTemplate(String template) {
- int nameId = res.getIdentifier("fr.free.nrw.commons:string/"
- + nameIdForTemplate(template), null, null);
- return (nameId != 0) ? res.getString(nameId) : template;
- }
-
- /**
- * 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
- */
- private 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;
- }
- }
-
-}
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 04f097d08..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Media.java
+++ /dev/null
@@ -1,428 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import fr.free.nrw.commons.location.LatLng;
-
-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];
- }
- };
-
- private static Pattern displayTitlePattern = Pattern.compile("(.*)(\\.\\w+)", Pattern.CASE_INSENSITIVE);
- // 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 @Nullable Date dateUploaded;
- protected int width;
- protected int height;
- protected String license;
- protected String creator;
- protected ArrayList categories; // as loaded at runtime?
- protected boolean requestedDeletion;
- private Map descriptions; // multilingual descriptions as loaded
- private HashMap tags = new HashMap<>();
- private @Nullable LatLng coordinates;
-
- /**
- * Provides local constructor
- */
- protected Media() {
- this.categories = new ArrayList<>();
- this.descriptions = new HashMap<>();
- }
-
- /**
- * Provides a minimal constructor
- *
- * @param filename Media filename
- */
- public Media(String filename) {
- this();
- this.filename = filename;
- }
-
- /**
- * Provide Media constructor
- * @param localUri Media URI
- * @param imageUrl Media image URL
- * @param filename Media filename
- * @param description Media description
- * @param dataLength Media date length
- * @param dateCreated Media creation date
- * @param dateUploaded Media date uploaded
- * @param creator Media creator
- */
- public Media(Uri localUri, String imageUrl, String filename, String description,
- long dataLength, Date dateCreated, @Nullable 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;
- }
-
- @SuppressWarnings("unchecked")
- 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());
- }
-
- /**
- * Gets tag of media
- * @param key Media key
- * @return Media tag
- */
- public Object getTag(String key) {
- return tags.get(key);
- }
-
- /**
- * Modifies( or creates a) tag of media
- * @param key Media key
- * @param value Media value
- */
- public void setTag(String key, Object value) {
- tags.put(key, value);
- }
-
- /**
- * Gets media display title
- * @return Media title
- */
- public String getDisplayTitle() {
- if (filename == null) {
- return "";
- }
- // FIXME: Gross hack because my regex skills suck maybe or I am too lazy who knows
- String title = getFilePageTitle().getDisplayText().replaceFirst("^File:", "");
- Matcher matcher = displayTitlePattern.matcher(title);
- if (matcher.matches()) {
- return matcher.group(1);
- } else {
- return title;
- }
- }
-
- /**
- * Gets file page title
- * @return New media page title
- */
- public PageTitle getFilePageTitle() {
- return new PageTitle("File:" + getFilename().replaceFirst("^File:", ""));
- }
-
- /**
- * Gets local URI
- * @return Media local URI
- */
- public Uri getLocalUri() {
- return localUri;
- }
-
- /**
- * Gets image URL
- * can be null.
- * @return Image URL
- */
- @Nullable
- public String getImageUrl() {
- if (imageUrl == null && this.getFilename() != null) {
- imageUrl = Utils.makeThumbBaseUrl(this.getFilename());
- }
- return imageUrl;
- }
-
- /**
- * Gets the name of the file.
- * @return file name as a string
- */
- public String getFilename() {
- return filename;
- }
-
- /**
- * Sets the name of the file.
- * @param filename the new name of the file
- */
- public void setFilename(String filename) {
- this.filename = filename;
- }
-
- /**
- * Gets the file description.
- * @return file description as a string
- */
- public String getDescription() {
- return description;
- }
-
- /**
- * Sets the file description.
- * @param description the new description of the file
- */
- public void setDescription(String description) {
- this.description = description;
- }
-
- /**
- * Gets the datalength of the file.
- * @return file datalength as a long
- */
- public long getDataLength() {
- return dataLength;
- }
-
- /**
- * Sets the datalength of the file.
- * @param dataLength as a long
- */
- public void setDataLength(long dataLength) {
- this.dataLength = dataLength;
- }
-
- /**
- * Gets the creation date of the file.
- * @return creation date as a Date
- */
- public Date getDateCreated() {
- return dateCreated;
- }
-
- /**
- * Sets the creation date of the file.
- * @param date creation date as a Date
- */
- public void setDateCreated(Date date) {
- this.dateCreated = date;
- }
-
- /**
- * Gets the upload date of the file.
- * Can be null.
- * @return upload date as a Date
- */
- public @Nullable
- Date getDateUploaded() {
- return dateUploaded;
- }
-
- /**
- * Gets the name of the creator of the file.
- * @return creator name as a String
- */
- public String getCreator() {
- return creator;
- }
-
- /**
- * Sets the creator name of the file.
- * @param creator creator name as a string
- */
- public void setCreator(String creator) {
- this.creator = creator;
- }
-
- /**
- * Gets the width of the media.
- * @return file width as an int
- */
- public int getWidth() {
- return width;
- }
-
- /**
- * Sets the width of the media.
- * @param width file width as an int
- */
- public void setWidth(int width) {
- this.width = width;
- }
-
- /**
- * Gets the height of the media.
- * @return file height as an int
- */
- public int getHeight() {
- return height;
- }
-
- /**
- * Sets the height of the media.
- * @param height file height as an int
- */
- public void setHeight(int height) {
- this.height = height;
- }
-
- /**
- * Gets the license name of the file.
- * @return license as a String
- */
- public String getLicense() {
- return license;
- }
-
- /**
- * Sets the license name of the file.
- * @param license license name as a String
- */
- public void setLicense(String license) {
- this.license = license;
- }
-
- /**
- * Gets the coordinates of where the file was created.
- * @return file coordinates as a LatLng
- */
- public @Nullable
- LatLng getCoordinates() {
- return coordinates;
- }
-
- /**
- * Sets the coordinates of where the file was created.
- * @param coordinates file coordinates as a LatLng
- */
- public void setCoordinates(@Nullable LatLng coordinates) {
- this.coordinates = coordinates;
- }
-
- /**
- * Gets the categories the file falls under.
- * @return file categories as an ArrayList of Strings
- */
- @SuppressWarnings("unchecked")
- public ArrayList getCategories() {
- return (ArrayList) categories.clone(); // feels dirty
- }
-
- /**
- * Sets the categories the file falls under.
- *
- * Does not append: i.e. will clear the current categories
- * and then add the specified ones.
- * @param categories file categories as a list of Strings
- */
- public void setCategories(List categories) {
- this.categories.clear();
- this.categories.addAll(categories);
- }
-
- /**
- * Modifies (or sets) media descriptions
- * @param descriptions Media descriptions
- */
- void setDescriptions(Map descriptions) {
- this.descriptions.clear();
- this.descriptions.putAll(descriptions);
- }
-
- /**
- * Gets media description in preferred language
- * @param preferredLanguage Language preferred
- * @return Description in preferred language
- */
- public String getDescription(String preferredLanguage) {
- if (descriptions.containsKey(preferredLanguage)) {
- // See if the requested language is there.
- return descriptions.get(preferredLanguage);
- } else if (descriptions.containsKey("en")) {
- // Ah, English. Language of the world, until the Chinese crush us.
- return descriptions.get("en");
- } else if (descriptions.containsKey("default")) {
- // No languages marked...
- return descriptions.get("default");
- } else {
- // FIXME: return the first available non-English description?
- return "";
- }
- }
-
- /**
- * Method of Parcelable interface
- * @return zero
- */
- @Override
- public int describeContents() {
- return 0;
- }
-
- /**
- * Creates a way to transfer information between two or more
- * activities.
- * @param parcel Instance of Parcel
- * @param flags Parcel flag
- */
- @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 void setRequestedDeletion(){
- requestedDeletion = true;
- }
-
- public boolean getRequestedDeletion(){
- return requestedDeletion;
- }
-}
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 affb57528..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
+++ /dev/null
@@ -1,314 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.support.annotation.Nullable;
-
-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.HashMap;
-import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import javax.inject.Inject;
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.DocumentBuilderFactory;
-import javax.xml.parsers.ParserConfigurationException;
-
-import fr.free.nrw.commons.location.LatLng;
-import fr.free.nrw.commons.mwapi.MediaResult;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import timber.log.Timber;
-
-/**
- * Fetch additional media data from the network that we don't store locally.
- *
- * This includes things like category lists and multilingual descriptions,
- * which are not intrinsic to the media and may change due to editing.
- */
-public class MediaDataExtractor {
- private final MediaWikiApi mediaWikiApi;
- private boolean fetched;
- private boolean deletionStatus;
- private ArrayList categories;
- private Map descriptions;
- private String license;
- private @Nullable LatLng coordinates;
-
- @Inject
- public MediaDataExtractor(MediaWikiApi mwApi) {
- this.categories = new ArrayList<>();
- this.descriptions = new HashMap<>();
- this.fetched = false;
- this.mediaWikiApi = mwApi;
- }
-
- /*
- * Actually fetch the data over the network.
- * todo: use local caching?
- *
- * Warning: synchronous i/o, call on a background thread
- */
- public void fetch(String filename, LicenseList licenseList) throws IOException {
- if (fetched) {
- throw new IllegalStateException("Tried to call MediaDataExtractor.fetch() again.");
- }
-
- try{
- deletionStatus = mediaWikiApi.pageExists("Commons:Deletion_requests/" + filename);
- Timber.d("Nominated for deletion: " + deletionStatus);
- }
- catch (Exception e){
- Timber.d(e.getMessage());
- }
-
- MediaResult result = mediaWikiApi.fetchMediaByFilename(filename);
-
- // In-page category links are extracted from source, as XML doesn't cover [[links]]
- extractCategories(result.getWikiSource());
-
- // Description template info is extracted from preprocessor XML
- processWikiParseTree(result.getParseTreeXmlSource(), licenseList);
- fetched = true;
- }
-
- /**
- * 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, LicenseList licenseList) 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 | 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");
- }
-
- Node coordinateTemplateNode = findTemplate(doc.getDocumentElement(), "location");
-
- if (coordinateTemplateNode != null) {
- coordinates = getCoordinates(coordinateTemplateNode);
- } else {
- coordinates = null;
- }
-
- /*
- 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
- */
- Timber.d("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) {
- Timber.d("MediaDataExtractor found no matching license for self parameter: %s; faking it", licenseTemplate);
- 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();
- Timber.d("MediaDataExtractor found self-license %s", 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();
- Timber.d("MediaDataExtractor found non-self license %s", this.license);
- break;
- }
- }
- }
- }
-
- private Node findTemplate(Element parentNode, String title_) throws IOException {
- String title = new PageTitle(title_).getDisplayText();
- 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 (title.equals(new PageTitle(foundTitle).getDisplayText())) {
- 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 {
- public abstract 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();
- }
-
- /**
- * Extracts the coordinates from the template.
- * Loops over the children of the coordinate template:
- * {{Location|47.50111007666667|19.055700301944444}}
- * and extracts the latitude and longitude.
- *
- * @param parentNode The node of the coordinates template.
- * @return Extracted coordinates.
- * @throws IOException Parsing failed.
- */
- private LatLng getCoordinates(Node parentNode) throws IOException {
- NodeList childNodes = parentNode.getChildNodes();
- double latitudeText = Double.parseDouble(childNodes.item(1).getTextContent());
- double longitudeText = Double.parseDouble(childNodes.item(2).getTextContent());
- return new LatLng(latitudeText, longitudeText, 0);
- }
-
- // 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 Media object to inject into
- */
- public void fill(Media media) {
- if (!fetched) {
- throw new IllegalStateException("Tried to call MediaDataExtractor.fill() before fetch().");
- }
-
- media.setCategories(categories);
- media.setDescriptions(descriptions);
- media.setCoordinates(coordinates);
- if (license != null) {
- media.setLicense(license);
- }
- if (deletionStatus){
- media.setRequestedDeletion();
- }
-
- // 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/MediaThumbnailFetchTask.java b/app/src/main/java/fr/free/nrw/commons/MediaThumbnailFetchTask.java
deleted file mode 100644
index a542cb363..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaThumbnailFetchTask.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.os.AsyncTask;
-import android.support.annotation.NonNull;
-
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-
-class MediaThumbnailFetchTask extends AsyncTask {
- protected final Media media;
- private MediaWikiApi mediaWikiApi;
-
- public MediaThumbnailFetchTask(@NonNull Media media, MediaWikiApi mwApi) {
- this.media = media;
- this.mediaWikiApi = mwApi;
- }
-
- @Override
- protected String doInBackground(String... params) {
- try {
- return mediaWikiApi.findThumbnailByFilename(params[0]);
- } catch (Exception e) {
- // Do something better!
- }
- return null;
- }
-}
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 52952036a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaWikiImageView.java
+++ /dev/null
@@ -1,122 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.graphics.drawable.VectorDrawableCompat;
-import android.support.v4.util.LruCache;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.widget.Toast;
-
-import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
-import com.facebook.drawee.view.SimpleDraweeView;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.di.ApplicationlessInjection;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import timber.log.Timber;
-
-public class MediaWikiImageView extends SimpleDraweeView {
- @Inject MediaWikiApi mwApi;
- @Inject LruCache thumbnailUrlCache;
-
- private ThumbnailFetchTask currentThumbnailTask;
-
- public MediaWikiImageView(Context context) {
- this(context, null);
- init();
- }
-
- public MediaWikiImageView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- init();
- }
-
- public MediaWikiImageView(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- init();
- }
-
- /**
- * Sets the media. Fetches its thumbnail if necessary.
- * @param media the new media
- */
- public void setMedia(Media media) {
- if (currentThumbnailTask != null) {
- currentThumbnailTask.cancel(true);
- }
- if (media == null) {
- return;
- }
-
- if (media.getFilename() != null && thumbnailUrlCache.get(media.getFilename()) != null) {
- setImageUrl(thumbnailUrlCache.get(media.getFilename()));
- } else {
- setImageUrl(null);
- currentThumbnailTask = new ThumbnailFetchTask(media, mwApi);
- currentThumbnailTask.execute(media.getFilename());
- }
- }
-
- @Override
- protected void onDetachedFromWindow() {
- if (currentThumbnailTask != null) {
- currentThumbnailTask.cancel(true);
- }
- super.onDetachedFromWindow();
- }
-
- /**
- * Initializes MediaWikiImageView.
- */
- private void init() {
- ApplicationlessInjection
- .getInstance(getContext()
- .getApplicationContext())
- .getCommonsApplicationComponent()
- .inject(this);
- setHierarchy(GenericDraweeHierarchyBuilder
- .newInstance(getResources())
- .setPlaceholderImage(VectorDrawableCompat.create(getResources(),
- R.drawable.ic_image_black_24dp, getContext().getTheme()))
- .setFailureImage(VectorDrawableCompat.create(getResources(),
- R.drawable.ic_image_black_24dp, getContext().getTheme()))
- .build());
- }
-
- /**
- * Displays the image from the URL.
- * @param url the URL of the image
- */
- private void setImageUrl(@Nullable String url) {
- setImageURI(url);
- }
-
- private class ThumbnailFetchTask extends MediaThumbnailFetchTask {
- ThumbnailFetchTask(@NonNull Media media, @NonNull MediaWikiApi mwApi) {
- super(media, mwApi);
- }
-
- @Override
- protected void onPostExecute(String result) {
- if (isCancelled()) {
- return;
- }
- if (TextUtils.isEmpty(result) && media.getLocalUri() != null) {
- result = media.getLocalUri().toString();
- } else {
- // only cache meaningful thumbnails received from network.
- try {
- thumbnailUrlCache.put(media.getFilename(), result);
- } catch (NullPointerException npe) {
- Timber.e("error when adding pic to cache " + npe);
-
- Toast.makeText(getContext(), R.string.error_while_cache, Toast.LENGTH_SHORT).show();
- }
- }
- setImageUrl(result);
- }
- }
-}
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/PageTitle.java b/app/src/main/java/fr/free/nrw/commons/PageTitle.java
deleted file mode 100644
index c4c52b1fc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/PageTitle.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.support.annotation.NonNull;
-
-public class PageTitle {
- private final String namespace;
- private final String titleKey;
-
- /**
- * Construct from a namespace-prefixed page name.
- * @param prefixedText namespace-prefixed page name
- */
- public PageTitle(@NonNull String prefixedText) {
- String[] segments = prefixedText.trim().replace(" ", "_").split(":", 2);
-
- // canonicalize and capitalize page title as done by MediaWiki
- if (segments.length == 2) {
- // TODO: canonicalize and capitalize namespace as well
- // see https://www.mediawiki.org/wiki/Manual:Title.php#Canonical_forms
- namespace = segments[0];
- titleKey = Utils.capitalize(segments[1]);
- } else {
- namespace = "";
- titleKey = Utils.capitalize(segments[0]);
- }
- }
-
- /**
- * Get the canonicalized title for displaying (such as "File:My example.jpg").
- *
- * @return canonical title
- */
- @NonNull
- public String getPrefixedText() {
- if (namespace.isEmpty()) {
- return titleKey;
- } else {
- return namespace + ":" + titleKey;
- }
- }
-
- /**
- * Get the canonical title for DB and URLs (such as "File:My_example.jpg").
- *
- * @return canonical title
- */
- @NonNull
- public String getDisplayText() {
- return getPrefixedText().replace("_", " ");
- }
-
- /**
- * Convert to a URI
- * (such as "https://commons.wikimedia.org/wiki/File:My_example.jpg").
- *
- * @return URI
- */
- @NonNull
- public Uri getCanonicalUri() {
- String uriStr = BuildConfig.HOME_URL + Uri.encode(getPrefixedText(), ":/");
- return Uri.parse(uriStr);
- }
-
-
- /**
- * Convert to a mobile URI
- * (such as "https://commons.m.wikimedia.org/wiki/File:My_example.jpg").
- *
- * @return URI
- */
- @NonNull
- public Uri getMobileUri() {
- String uriStr = BuildConfig.MOBILE_HOME_URL + Uri.encode(getPrefixedText(), ":/");
- return Uri.parse(uriStr);
- }
-
- /**
- * Get the canonical title without namespace.
- * @return title
- */
- @NonNull
- public String getText() {
- return titleKey;
- }
-
- /**
- * Gets the canonicalized title for displaying (such as "File:My example.jpg").
- *
- * Essentially equivalent to getPrefixedText
- * @return canonical title as a String
- */
- @Override
- public String toString() {
- return getPrefixedText();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/TokensTranslations.java b/app/src/main/java/fr/free/nrw/commons/TokensTranslations.java
deleted file mode 100644
index 92278b6e9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/TokensTranslations.java
+++ /dev/null
@@ -1,105 +0,0 @@
-package fr.free.nrw.commons;
-
-import java.util.HashMap;
-
-/**
- * Created by Dell on 3/16/2018.
- */
-
-public class TokensTranslations {
- HashMap translationToken = new HashMap();
-
- public void initailize() {
- translationToken.put("Kazakh", "ab");
- translationToken.put("Afrikaans", "af");
- translationToken.put("Arabic", "ar");
- translationToken.put("Bengali", "as");
- translationToken.put("Asturianu", "ast");
- translationToken.put("azərbaycanca", "az");
- translationToken.put("Bikol Central", "bcl");
- translationToken.put("Bulgarain","bg");
- translationToken.put("বাংলা", "bn");
- translationToken.put("Brezhoneg", "br");
- translationToken.put("Bosanski", "bs");
- translationToken.put("català", "ca");
- translationToken.put("کوردی","ckb");
- translationToken.put("čeština", "cs");
- translationToken.put("kaszëbsczi", "csb");
- translationToken.put("Cymraeg", "cy");
- translationToken.put("dansk", "da");
- translationToken.put("Deutsch", "de");
- translationToken.put("Zazaki", "diq");
- translationToken.put("डोटेली","diq");
- translationToken.put("Ελληνικά","el");
- translationToken.put("euskara","eu");
- translationToken.put("español", "es");
- translationToken.put("فارسی","fa");
- translationToken.put("suomi", "fi");
- translationToken.put("føroyskt", "fo");
- translationToken.put("français", "fr");
- translationToken.put("Nordfriisk", "frr");
- translationToken.put("galego", "gr");
- translationToken.put("Hawaiʻi", "haw");
- translationToken.put("עברית","he");
- translationToken.put("हिन्दी","hi");
- translationToken.put("Hunsrik", "hrx");
- translationToken.put("hornjoserbsce", "hsb");
- translationToken.put("magyar","hu");
- translationToken.put("interlingua","ia");
- translationToken.put("Bahasa Indonesia", "id");
- translationToken.put("íslenska","is");
- translationToken.put("Italian","it");
- translationToken.put("japanese","ja");
- translationToken.put("Basa Jawa","jv");
- translationToken.put("ქართული", "ka");
- translationToken.put("Taqbaylit","kab");
- translationToken.put(" ភាសាខ្មែរ","km");
- translationToken.put("ಕನ್ನಡ", "kn");
- translationToken.put("한국어", "ko");
- translationToken.put("къарачай-малкъар","krc");
- translationToken.put("Кыргызча","ky");
- translationToken.put("latina","la");
- translationToken.put("Lëtzebuergesch","lb");
- translationToken.put("lietuvių", "lt");
- translationToken.put("latviešu","lv");
- translationToken.put("Malagasy","mg");
- translationToken.put("македонски", "mk");
- translationToken.put("മലയാളം","ml");
- translationToken.put("монгол","mn");
- translationToken.put("मराठी","mr");
- translationToken.put("Bahasa Melayu","ms");
- translationToken.put("Malti","mt");
- translationToken.put("norsk bokmål", "nb");
- translationToken.put("नेपाली","ne");
- translationToken.put("Nederlands","nl");
- translationToken.put("occitan","oc");
- translationToken.put("ଓଡ଼ିଆ","or");
- translationToken.put("ਪੰਜਾਬੀ","pa");
- translationToken.put("polsk", "pl");
- translationToken.put("Piemontèis","pms");
- translationToken.put("پښتو","ps");
- translationToken.put("português","pt");
- translationToken.put("română","ro");
- translationToken.put("русский","ru");
- translationToken.put(" سنڌي","sd");
- translationToken.put(" සිංහල","si");
- translationToken.put("slovenčina","sk");
- translationToken.put(" سرائیکی","skr");
- translationToken.put("Basa Sunda","su");
- translationToken.put("svenska","sv");
- translationToken.put("தமிழ்", "ta");
- translationToken.put("ತುಳು", "tcy");
- translationToken.put(" తెలుగు","te");
- translationToken.put(" ไทย","th");
- translationToken.put("Türkçe","tr");
- translationToken.put("українська","uk");
- translationToken.put("اردو","ur");
- translationToken.put("Tiếng Việt","vi");
- translationToken.put(" მარგალური", "xmf");
- translationToken.put("ייִדיש","yi");
- }
-
- public String getTranslationToken ( String language){
- return translationToken.get(language);
- }
-}
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 1fc2d1f99..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Utils.java
+++ /dev/null
@@ -1,226 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.preference.PreferenceManager;
-import android.support.annotation.NonNull;
-import android.support.customtabs.CustomTabsIntent;
-import android.support.v4.content.ContextCompat;
-import android.view.View;
-import android.widget.Toast;
-
-import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.codec.digest.DigestUtils;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.util.Locale;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import fr.free.nrw.commons.settings.Prefs;
-import timber.log.Timber;
-
-import static android.widget.Toast.LENGTH_SHORT;
-
-public class Utils {
-
- /**
- * 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;
- }
- }
-
- /**
- * Creates an URL for thumbnail
- *
- * @param filename Thumbnail file name
- * @return URL of thumbnail
- */
- public static String makeThumbBaseUrl(@NonNull String filename) {
- String name = new PageTitle(filename).getPrefixedText();
- String sha = new String(Hex.encodeHex(DigestUtils.md5(name)));
- return String.format("%s/%s/%s/%s", BuildConfig.IMAGE_URL_BASE, sha.substring(0, 1), sha.substring(0, 2), urlEncode(name));
- }
-
- /**
- * URL Encode an URL in UTF-8 format
- * @param url Unformatted URL
- * @return Encoded URL
- */
- public static String urlEncode(String url) {
- try {
- return URLEncoder.encode(url, "utf-8");
- } catch (UnsupportedEncodingException e) {
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Capitalizes the first character of a string.
- *
- * @param string String to alter
- * @return string with capitalized first character
- */
- public static String capitalize(String string) {
- if(string.length() > 0) {
- return string.substring(0, 1).toUpperCase(Locale.getDefault()) + string.substring(1);
- } else {
- return string;
- }
- }
-
- /**
- * Generates licence name with given ID
- * @param license License ID
- * @return Name of license
- */
- public static int licenseNameFor(String license) {
- switch (license) {
- case Prefs.Licenses.CC_BY_3:
- return R.string.license_name_cc_by;
- case Prefs.Licenses.CC_BY_4:
- return R.string.license_name_cc_by_four;
- case Prefs.Licenses.CC_BY_SA_3:
- return R.string.license_name_cc_by_sa;
- case Prefs.Licenses.CC_BY_SA_4:
- return R.string.license_name_cc_by_sa_four;
- case Prefs.Licenses.CC0:
- return R.string.license_name_cc0;
- case Prefs.Licenses.CC_BY: // for backward compatibility to v2.1
- return R.string.license_name_cc_by_3_0;
- case Prefs.Licenses.CC_BY_SA: // for backward compatibility to v2.1
- return R.string.license_name_cc_by_sa_3_0;
- }
- throw new RuntimeException("Unrecognized license value: " + license);
- }
-
- /**
- * Adds extension to filename. Converts to .jpg if system provides .jpeg, adds .jpg if no extension detected
- * @param title File name
- * @param extension Correct extension
- * @return File with correct extension
- */
- public static String fixExtension(String title, String extension) {
- Pattern jpegPattern = Pattern.compile("\\.jpeg$", Pattern.CASE_INSENSITIVE);
-
- // People are used to ".jpg" more than ".jpeg" which the system gives us.
- if (extension != null && extension.toLowerCase(Locale.ENGLISH).equals("jpeg")) {
- extension = "jpg";
- }
- title = jpegPattern.matcher(title).replaceFirst(".jpg");
- if (extension != null && !title.toLowerCase(Locale.getDefault())
- .endsWith("." + extension.toLowerCase(Locale.ENGLISH))) {
- title += "." + extension;
- }
-
- // If extension is still null, make it jpg. (Hotfix for https://github.com/commons-app/apps-android-commons/issues/228)
- // If title has an extension in it, if won't be true
- if (extension == null && title.lastIndexOf(".")<=0) {
- extension = "jpg";
- title += "." + extension;
- }
-
- return title;
- }
-
- /**
- * Tells whether dark theme is active or not
- * @param context Activity context
- * @return The state of dark theme
- */
- public static boolean isDarkTheme(Context context) {
- return PreferenceManager.getDefaultSharedPreferences(context).getBoolean("theme", false);
- }
-
- /**
- * Will be used to fetch the logs generated by the app ever since the beginning of times....
- * i.e. since the time the app started.
- *
- * @return String containing all the logs since the time the app started
- */
- public static String getAppLogs() {
- final String processId = Integer.toString(android.os.Process.myPid());
-
- StringBuilder stringBuilder = new StringBuilder();
-
- try {
- String[] command = new String[]{"logcat","-d","-v","threadtime"};
-
- Process process = Runtime.getRuntime().exec(command);
-
- BufferedReader bufferedReader = new BufferedReader(
- new InputStreamReader(process.getInputStream())
- );
-
- String line;
- while ((line = bufferedReader.readLine()) != null) {
- if (line.contains(processId)) {
- stringBuilder.append(line);
- }
- }
- } catch (IOException ioe) {
- Timber.e("getAppLogs failed", ioe);
- }
-
- return stringBuilder.toString();
- }
-
- public static void rateApp(Context context) {
- final String appPackageName = BuildConfig.class.getPackage().getName();
- try {
- context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + appPackageName)));
- }
- catch (android.content.ActivityNotFoundException anfe) {
- context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=" + appPackageName)));
- }
- }
-
- public static void handleWebUrl(Context context, Uri url) {
- Timber.d("Launching web url %s", url.toString());
- Intent browserIntent = new Intent(Intent.ACTION_VIEW, url);
- if (browserIntent.resolveActivity(context.getPackageManager()) == null) {
- Toast toast = Toast.makeText(context, context.getString(R.string.no_web_browser), LENGTH_SHORT);
- toast.show();
- return;
- }
-
- CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
- builder.setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor));
- builder.setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.primaryDarkColor));
- builder.setExitAnimations(context, android.R.anim.slide_in_left, android.R.anim.slide_out_right);
- CustomTabsIntent customTabsIntent = builder.build();
- customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- customTabsIntent.launchUrl(context, url);
- }
-
- /**
- * To take screenshot of the screen and return it in Bitmap format
- *
- * @param view
- * @return
- */
- public static Bitmap getScreenShot(View view) {
- View screenView = view.getRootView();
- screenView.setDrawingCacheEnabled(true);
- Bitmap bitmap = Bitmap.createBitmap(screenView.getDrawingCache());
- screenView.setDrawingCacheEnabled(false);
- return bitmap;
- }
-
-}
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 c1bc37f46..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.content.Context;
-import android.content.Intent;
-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.quiz.QuizActivity;
-import fr.free.nrw.commons.theme.BaseActivity;
-
-public class WelcomeActivity extends BaseActivity {
-
- @BindView(R.id.welcomePager) ViewPager pager;
- @BindView(R.id.welcomePagerIndicator) CirclePageIndicator indicator;
-
- private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
- private boolean isQuiz;
-
- /**
- * Initialises exiting fields and dependencies
- *
- * @param savedInstanceState WelcomeActivity bundled data
- */
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_welcome);
-
- if(getIntent() != null) {
- Bundle bundle = getIntent().getExtras();
- if (bundle != null) {
- isQuiz = bundle.getBoolean("isQuiz");
- }
- } else{
- isQuiz = false;
- }
-
- ButterKnife.bind(this);
-
- pager.setAdapter(adapter);
- indicator.setViewPager(pager);
- adapter.setCallback(this::finish);
- }
-
- /**
- * References WelcomePageAdapter to null before the activity is destroyed
- */
- @Override
- public void onDestroy() {
- if(isQuiz){
- Intent i = new Intent(WelcomeActivity.this, QuizActivity.class);
- startActivity(i);
- }
- adapter.setCallback(null);
- super.onDestroy();
- }
-
- /**
- * Creates a way to change current activity to WelcomeActivity
- *
- * @param context Activity context
- */
- public static void startYourself(Context context) {
- Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
- context.startActivity(welcomeIntent);
- }
-}
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 bca548632..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package fr.free.nrw.commons;
-
-import android.net.Uri;
-import android.support.annotation.Nullable;
-import android.support.v4.view.PagerAdapter;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import butterknife.Optional;
-
-public class WelcomePagerAdapter extends PagerAdapter {
- 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
- };
- private static final int PAGE_FINAL = 4;
- private Callback callback;
- private ViewGroup container;
-
- /**
- * Changes callback to provided one
- *
- * @param callback New callback
- * it can be null.
- */
- public void setCallback(@Nullable Callback callback) {
- this.callback = callback;
- }
-
- /**
- * Gets total number of layouts
- * @return Number of layouts
- */
- @Override
- public int getCount() {
- return PAGE_LAYOUTS.length;
- }
-
- /**
- * Compares given view with provided object
- * @param view Adapter view
- * @param object Adapter object
- * @return Equality between view and object
- */
- @Override
- public boolean isViewFromObject(View view, Object object) {
- return (view == object);
- }
-
- @Override
- public Object instantiateItem(ViewGroup container, int position) {
- this.container=container;
- LayoutInflater inflater = LayoutInflater.from(container.getContext());
- ViewGroup layout = (ViewGroup) inflater.inflate(PAGE_LAYOUTS[position], container, false);
- if( BuildConfig.FLAVOR == "beta"){
- TextView textView = (TextView) layout.findViewById(R.id.welcomeYesButton);
- if( textView.getVisibility() != View.VISIBLE){
- textView.setVisibility(View.VISIBLE);
- }
- ViewHolder holder = new ViewHolder(layout);
- layout.setTag(holder);
- } else {
- if (position == PAGE_FINAL) {
- ViewHolder holder = new ViewHolder(layout);
- layout.setTag(holder);
- }
- }
- container.addView(layout);
- return layout;
- }
-
- /**
- * Provides a way to remove an item from container
- * @param container Adapter view group container
- * @param position Index of item
- * @param obj Adapter object
- */
- @Override
- public void destroyItem(ViewGroup container, int position, Object obj) {
- container.removeView((View) obj);
- }
-
- public interface Callback {
- void onYesClicked();
- }
-
- class ViewHolder {
- ViewHolder(View view) {
- ButterKnife.bind(this, view);
- }
-
- /**
- * Triggers on click callback on button click
- */
- @OnClick(R.id.welcomeYesButton)
- void onClicked() {
- if (callback != null) {
- callback.onYesClicked();
- }
- }
-
- @Optional
- @OnClick(R.id.welcomeInfo)
- void onHelpClicked () {
- try {
- Utils.handleWebUrl(container.getContext(),Uri.parse("https://commons.wikimedia.org/wiki/Help:Contents" ));
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
new file mode 100644
index 000000000..0cb88c48b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
@@ -0,0 +1,70 @@
+package fr.free.nrw.commons
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.net.toUri
+import androidx.viewpager.widget.PagerAdapter
+import fr.free.nrw.commons.utils.UnderlineUtils.setUnderlinedText
+import fr.free.nrw.commons.utils.handleWebUrl
+
+class WelcomePagerAdapter : PagerAdapter() {
+ /**
+ * Gets total number of layouts
+ * @return Number of layouts
+ */
+ override fun getCount(): Int = PAGE_LAYOUTS.size
+
+ /**
+ * Compares given view with provided object
+ * @param view Adapter view
+ * @param obj Adapter object
+ * @return Equality between view and object
+ */
+ override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj)
+
+ /**
+ * Provides a way to remove an item from container
+ * @param container Adapter view group container
+ * @param position Index of item
+ * @param obj Adapter object
+ */
+ override fun destroyItem(container: ViewGroup, position: Int, obj: Any) =
+ container.removeView(obj as View)
+
+ override fun instantiateItem(container: ViewGroup, position: Int): Any {
+ val inflater = LayoutInflater.from(container.context)
+ val layout = inflater.inflate(PAGE_LAYOUTS[position], container, false) as ViewGroup
+
+ // If final page
+ if (position == PAGE_LAYOUTS.size - 1) {
+ // Add link to more information
+ val moreInfo = layout.findViewById(R.id.welcomeInfo)
+ setUnderlinedText(moreInfo, R.string.welcome_help_button_text)
+ moreInfo.setOnClickListener {
+ handleWebUrl(
+ container.context,
+ "https://commons.wikimedia.org/wiki/Help:Contents".toUri()
+ )
+ }
+
+ // Handle click of finishTutorialButton ("YES!" button) inside layout
+ layout.findViewById(R.id.finishTutorialButton)
+ .setOnClickListener { view: View? -> (container.context as WelcomeActivity).finishTutorial() }
+ }
+
+ container.addView(layout)
+ return layout
+ }
+
+ companion object {
+ private val PAGE_LAYOUTS = intArrayOf(
+ R.layout.welcome_wikipedia,
+ R.layout.welcome_do_upload,
+ R.layout.welcome_dont_upload,
+ R.layout.welcome_image_example,
+ R.layout.welcome_final
+ )
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.java b/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.java
deleted file mode 100644
index 98fe099da..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/Achievements.java
+++ /dev/null
@@ -1,161 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-/**
- * represnts Achievements class ans stores all the parameters
- */
-public class Achievements {
- private int uniqueUsedImages;
- private int articlesUsingImages;
- private int thanksReceived;
- private int imagesEditedBySomeoneElse;
- private int featuredImages;
- private int imagesUploaded;
- private int revertCount;
-
- public Achievements(){
-
- }
-
- /**
- * constructor for achievements class to set its data members
- * @param uniqueUsedImages
- * @param articlesUsingImages
- * @param thanksReceived
- * @param imagesEditedBySomeoneElse
- * @param featuredImages
- * @param imagesUploaded
- * @param revertCount
- */
- public Achievements(int uniqueUsedImages,
- int articlesUsingImages,
- int thanksReceived,
- int imagesEditedBySomeoneElse,
- int featuredImages,
- int imagesUploaded,
- int revertCount) {
- this.uniqueUsedImages = uniqueUsedImages;
- this.articlesUsingImages = articlesUsingImages;
- this.thanksReceived = thanksReceived;
- this.imagesEditedBySomeoneElse = imagesEditedBySomeoneElse;
- this.featuredImages = featuredImages;
- this.imagesUploaded = imagesUploaded;
- this.revertCount = revertCount;
- }
-
- /**
- * Get Achievements object from FeedbackResponse
- *
- * @param response
- * @return
- */
- public static Achievements from(FeedbackResponse response) {
- return new Achievements(response.getUniqueUsedImages(),
- response.getArticlesUsingImages(),
- response.getThanksReceived(),
- response.getImagesEditedBySomeoneElse(),
- response.getFeaturedImages().getQualityImages()
- + response.getFeaturedImages().getFeaturedPicturesOnWikimediaCommons(),
- 0,
- response.getDeletedUploads());
- }
-
- /**
- * getter function to get count of images uploaded
- * @return
- */
- public int getImagesUploaded() {
- return imagesUploaded;
- }
-
- /**
- * getter function to get count of featured images
- * @return
- */
- public int getFeaturedImages() {
- return featuredImages;
- }
-
- /**
- * getter function to get count of thanks received
- * @return
- */
- public int getThanksReceived() {
- return thanksReceived;
- }
-
- /**
- * getter function to get count of unique images used by wiki
- * @return
- */
- public int getUniqueUsedImages() {
- return uniqueUsedImages;
- }
-
- /**
- * setter function to count of images uploaded
- * @param imagesUploaded
- */
- public void setImagesUploaded(int imagesUploaded) {
- this.imagesUploaded = imagesUploaded;
- }
-
- /**
- * setter function to set count of featured images
- * @param featuredImages
- */
- public void setFeaturedImages(int featuredImages) {
- this.featuredImages = featuredImages;
- }
-
- /**
- * setter function to set the count of images edited by someone
- * @param imagesEditedBySomeoneElse
- */
- public void setImagesEditedBySomeoneElse(int imagesEditedBySomeoneElse) {
- this.imagesEditedBySomeoneElse = imagesEditedBySomeoneElse;
- }
-
- /**
- * setter function to set count of thanks received
- * @param thanksReceived
- */
- public void setThanksReceived(int thanksReceived) {
- this.thanksReceived = thanksReceived;
- }
-
- /**
- * setter function to count of articles using images uploaded
- * @param articlesUsingImages
- */
- public void setArticlesUsingImages(int articlesUsingImages) {
- this.articlesUsingImages = articlesUsingImages;
- }
-
- /**
- * setter function to set count of uniques images used by wiki
- * @param uniqueUsedImages
- */
- public void setUniqueUsedImages(int uniqueUsedImages) {
- this.uniqueUsedImages = uniqueUsedImages;
- }
-
- /**
- * to set count of images reverted
- * @param revertCount
- */
- public void setRevertCount(int revertCount) {
- this.revertCount = revertCount;
- }
-
- /**
- * used to calculate the percentages of images that haven't been reverted
- * @return
- */
- public int getNotRevertPercentage(){
- try {
- return ((imagesUploaded - revertCount) * 100)/imagesUploaded;
- } catch (ArithmeticException divideByZero ){
- return 100;
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java b/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java
deleted file mode 100644
index 1bc1cc27d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/AchievementsActivity.java
+++ /dev/null
@@ -1,399 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-import android.accounts.Account;
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-import android.net.Uri;
-import android.os.Bundle;
-import android.support.v4.content.res.ResourcesCompat;
-import android.support.v7.app.AlertDialog;
-import android.support.v7.widget.Toolbar;
-import android.util.DisplayMetrics;
-import android.view.ContextThemeWrapper;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import com.dinuscxj.progressbar.CircleProgressBar;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.util.Objects;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-/**
- * activity for sharing feedback on uploaded activity
- */
-public class AchievementsActivity extends NavigationBaseActivity {
-
- private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4;
- private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3;
-
- private LevelController.LevelInfo levelInfo;
-
- @BindView(R.id.achievement_badge)
- ImageView imageView;
- @BindView(R.id.achievement_level)
- TextView levelNumber;
- @BindView(R.id.toolbar)
- Toolbar toolbar;
- @BindView(R.id.thanks_received)
- TextView thanksReceived;
- @BindView(R.id.images_uploaded_progressbar)
- CircleProgressBar imagesUploadedProgressbar;
- @BindView(R.id.images_used_by_wiki_progressbar)
- CircleProgressBar imagesUsedByWikiProgessbar;
- @BindView(R.id.image_reverts_progressbar)
- CircleProgressBar imageRevertsProgressbar;
- @BindView(R.id.image_featured)
- TextView imagesFeatured;
- @BindView(R.id.images_revert_limit_text)
- TextView imagesRevertLimitText;
- @BindView(R.id.progressBar)
- ProgressBar progressBar;
- @BindView(R.id.layout_image_uploaded)
- RelativeLayout layoutImageUploaded;
- @BindView(R.id.layout_image_reverts)
- RelativeLayout layoutImageReverts;
- @BindView(R.id.layout_image_used_by_wiki)
- RelativeLayout layoutImageUsedByWiki;
- @BindView(R.id.layout_statistics)
- LinearLayout layoutStatistics;
- @Inject
- SessionManager sessionManager;
- @Inject
- MediaWikiApi mediaWikiApi;
-
- private CompositeDisposable compositeDisposable = new CompositeDisposable();
-
- /**
- * This method helps in the creation Achievement screen and
- * dynamically set the size of imageView
- *
- * @param savedInstanceState Data bundle
- */
- @Override
- @SuppressLint("StringFormatInvalid")
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_achievements);
- ButterKnife.bind(this);
- /**
- * DisplayMetrics used to fetch the size of the screen
- */
- DisplayMetrics displayMetrics = new DisplayMetrics();
- getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
- int height = displayMetrics.heightPixels;
- int width = displayMetrics.widthPixels;
-
- /**
- * Used for the setting the size of imageView at runtime
- */
- RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams)
- imageView.getLayoutParams();
- params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO);
- params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO);
- imageView.setImageResource(R.drawable.badge);
- imageView.requestLayout();
-
- setSupportActionBar(toolbar);
- progressBar.setVisibility(View.VISIBLE);
- hideLayouts();
- setAchievements();
- initDrawer();
- }
-
- /**
- * to invoke the AlertDialog on clicking info button
- */
- @OnClick(R.id.achievement_info)
- public void showInfoDialog(){
- launchAlert(getResources().getString(R.string.Achievements)
- ,getResources().getString(R.string.achievements_info_message));
- }
-
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- // Inflate the menu; this adds items to the action bar if it is present.
- getMenuInflater().inflate(R.menu.menu_about, menu);
- return true;
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- int id = item.getItemId();
- if (id == R.id.share_app_icon) {
- View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
- Bitmap screenShot = Utils.getScreenShot(rootView);
- showAlert(screenShot);
- }
-
- return super.onOptionsItemSelected(item);
- }
-
- /**
- * To take bitmap and store it temporary storage and share it
- *
- * @param bitmap
- */
- void shareScreen(Bitmap bitmap) {
- try {
- File file = new File(this.getExternalCacheDir(), "screen.png");
- FileOutputStream fOut = new FileOutputStream(file);
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut);
- fOut.flush();
- fOut.close();
- file.setReadable(true, false);
- final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
- intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file));
- intent.setType("image/png");
- startActivity(Intent.createChooser(intent, "Share image via"));
- } catch (IOException e) {
- //Do Nothing
- }
- }
-
- /**
- * To call the API to get results in form Single
- * which then calls parseJson when results are fetched
- */
- private void setAchievements() {
- if(checkAccount()) {
- compositeDisposable.add(mediaWikiApi
- .getAchievements(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- response -> {
- if (response != null) {
- setUploadCount(Achievements.from(response));
- } else {
- onError();
- }
- },
- t -> {
- Timber.e(t, "Fetching achievements statistics failed");
- onError();
- }
- ));
- }
- }
-
- /**
- * Shows a generic error toast when error occurs while loading achievements or uploads
- */
- private void onError() {
- ViewUtil.showLongToast(this, getResources().getString(R.string.error_occurred));
- progressBar.setVisibility(View.GONE);
- }
-
- /**
- * used to the count of images uploaded by user
- */
- private void setUploadCount(Achievements achievements) {
- if(checkAccount()) {
- compositeDisposable.add(mediaWikiApi
- .getUploadCount(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- uploadCount -> setAchievementsUploadCount(achievements, uploadCount),
- t -> {
- Timber.e(t, "Fetching upload count failed");
- onError();
- }
- ));
- }
- }
-
- /**
- * used to set achievements upload count and call hideProgressbar
- * @param uploadCount
- */
- private void setAchievementsUploadCount(Achievements achievements, int uploadCount) {
- achievements.setImagesUploaded(uploadCount);
- hideProgressBar(achievements);
- }
-
- /**
- * used to the uploaded images progressbar
- * @param uploadCount
- */
- private void setUploadProgress(int uploadCount){
- imagesUploadedProgressbar.setProgress
- (100*uploadCount/levelInfo.getMaxUploadCount());
- imagesUploadedProgressbar.setProgressTextFormatPattern
- (uploadCount +"/" + levelInfo.getMaxUploadCount() );
- }
-
- /**
- * used to set the non revert image percentage
- * @param notRevertPercentage
- */
- private void setImageRevertPercentage(int notRevertPercentage){
- imageRevertsProgressbar.setProgress(notRevertPercentage);
- String revertPercentage = Integer.toString(notRevertPercentage);
- imageRevertsProgressbar.setProgressTextFormatPattern(revertPercentage + "%%");
- imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%");
- }
-
- /**
- * Used the inflate the fetched statistics of the images uploaded by user
- * and assign badge and level
- * @param achievements
- */
- private void inflateAchievements(Achievements achievements) {
- thanksReceived.setText(Integer.toString(achievements.getThanksReceived()));
- imagesUsedByWikiProgessbar.setProgress
- (100*achievements.getUniqueUsedImages()/levelInfo.getMaxUniqueImages() );
- imagesUsedByWikiProgessbar.setProgressTextFormatPattern
- (achievements.getUniqueUsedImages() + "/" + levelInfo.getMaxUniqueImages());
- imagesFeatured.setText(Integer.toString(achievements.getFeaturedImages()));
- String levelUpInfoString = getString(R.string.level);
- levelUpInfoString += " " + Integer.toString(levelInfo.getLevelNumber());
- levelNumber.setText(levelUpInfoString);
- final ContextThemeWrapper wrapper = new ContextThemeWrapper(this, levelInfo.getLevelStyle());
- Drawable drawable = ResourcesCompat.getDrawable(getResources(), R.drawable.badge, wrapper.getTheme());
- Bitmap bitmap = BitmapUtils.drawableToBitmap(drawable);
- BitmapDrawable bitmapImage = BitmapUtils.writeOnDrawable(bitmap, Integer.toString(levelInfo.getLevelNumber()),this);
- imageView.setImageDrawable(bitmapImage);
- }
-
- /**
- * Creates a way to change current activity to AchievementActivity
- * @param context
- */
- public static void startYourself(Context context) {
- Intent intent = new Intent(context, AchievementsActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
- intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
- context.startActivity(intent);
- }
-
- /**
- * to hide progressbar
- */
- private void hideProgressBar(Achievements achievements) {
- if (progressBar != null) {
- levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(),
- achievements.getUniqueUsedImages(),
- achievements.getNotRevertPercentage());
- inflateAchievements(achievements);
- setUploadProgress(achievements.getImagesUploaded());
- setImageRevertPercentage(achievements.getNotRevertPercentage());
- progressBar.setVisibility(View.GONE);
- layoutImageReverts.setVisibility(View.VISIBLE);
- layoutImageUploaded.setVisibility(View.VISIBLE);
- layoutImageUsedByWiki.setVisibility(View.VISIBLE);
- layoutStatistics.setVisibility(View.VISIBLE);
- imageView.setVisibility(View.VISIBLE);
- levelNumber.setVisibility(View.VISIBLE);
- }
- }
-
- /**
- * used to hide the layouts while fetching results from api
- */
- private void hideLayouts(){
- layoutImageUsedByWiki.setVisibility(View.INVISIBLE);
- layoutImageUploaded.setVisibility(View.INVISIBLE);
- layoutImageReverts.setVisibility(View.INVISIBLE);
- layoutStatistics.setVisibility(View.INVISIBLE);
- imageView.setVisibility(View.INVISIBLE);
- levelNumber.setVisibility(View.INVISIBLE);
- }
-
- /**
- * It display the alertDialog with Image of screenshot
- * @param screenshot
- */
- public void showAlert(Bitmap screenshot){
- AlertDialog.Builder alertadd = new AlertDialog.Builder(AchievementsActivity.this);
- LayoutInflater factory = LayoutInflater.from(AchievementsActivity.this);
- final View view = factory.inflate(R.layout.image_alert_layout, null);
- ImageView screenShotImage = (ImageView) view.findViewById(R.id.alert_image);
- screenShotImage.setImageBitmap(screenshot);
- TextView shareMessage = (TextView) view.findViewById(R.id.alert_text);
- shareMessage.setText(R.string.achievements_share_message);
- alertadd.setView(view);
- alertadd.setPositiveButton("Proceed", (dialog, which) -> shareScreen(screenshot));
- alertadd.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel());
- alertadd.show();
- }
-
- @OnClick(R.id.images_upload_info)
- public void showUploadInfo(){
- launchAlert(getResources().getString(R.string.images_uploaded)
- ,getResources().getString(R.string.images_uploaded_explanation));
- }
-
- @OnClick(R.id.images_reverted_info)
- public void showRevertedInfo(){
- launchAlert(getResources().getString(R.string.image_reverts)
- ,getResources().getString(R.string.images_reverted_explanation));
- }
-
- @OnClick(R.id.images_used_by_wiki_info)
- public void showUsedByWikiInfo(){
- launchAlert(getResources().getString(R.string.images_used_by_wiki)
- ,getResources().getString(R.string.images_used_explanation));
- }
-
- /**
- * takes title and message as input to display alerts
- * @param title
- * @param message
- */
- private void launchAlert(String title, String message){
- new AlertDialog.Builder(AchievementsActivity.this)
- .setTitle(title)
- .setMessage(message)
- .setCancelable(true)
- .setNeutralButton(android.R.string.ok, (dialog, id) -> dialog.cancel())
- .create()
- .show();
- }
-
- /**
- * check to ensure that user is logged in
- * @return
- */
- private boolean checkAccount(){
- Account currentAccount = sessionManager.getCurrentAccount();
- if(currentAccount == null) {
- Timber.d("Current account is null");
- ViewUtil.showLongToast(this, getResources().getString(R.string.user_not_logged_in));
- sessionManager.forceLogin(this);
- return false;
- }
- return true;
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/BitmapUtils.java b/app/src/main/java/fr/free/nrw/commons/achievements/BitmapUtils.java
deleted file mode 100644
index b7400117d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/BitmapUtils.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.Rect;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-
-public class BitmapUtils {
-
- /**
- * write level Number on the badge
- * @param bm
- * @param text
- * @return
- */
- public static BitmapDrawable writeOnDrawable(Bitmap bm, String text, Context context){
- Bitmap.Config config = bm.getConfig();
- if(config == null){
- config = Bitmap.Config.ARGB_8888;
- }
- Bitmap bitmap = Bitmap.createBitmap(bm.getWidth(),bm.getHeight(),config);
- Canvas canvas = new Canvas(bitmap);
- canvas.drawBitmap(bm, 0, 0, null);
- Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
- paint.setStyle(Paint.Style.FILL);
- paint.setColor(Color.WHITE);
- paint.setTextSize(Math.round(canvas.getHeight()/2));
- paint.setTextAlign(Paint.Align.CENTER);
- Rect rectText = new Rect();
- paint.getTextBounds(text,0, text.length(),rectText);
- canvas.drawText(text, Math.round(canvas.getWidth()/2),Math.round(canvas.getHeight()/1.35), paint);
- return new BitmapDrawable(context.getResources(), bitmap);
- }
-
- /**
- * Convert Drawable to bitmap
- * @param drawable
- * @return
- */
- public static Bitmap drawableToBitmap (Drawable drawable) {
- if (drawable instanceof BitmapDrawable) {
- return ((BitmapDrawable)drawable).getBitmap();
- }
- Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
- Canvas canvas = new Canvas(bitmap);
- drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
- drawable.draw(canvas);
- return bitmap;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.java b/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.java
deleted file mode 100644
index 8b9992e28..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/FeaturedImages.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-import com.google.gson.annotations.SerializedName;
-
-public class FeaturedImages {
-
- @SerializedName("Quality_images")
- private final int qualityImages;
-
- @SerializedName("Featured_pictures_on_Wikimedia_Commons")
- private final int featuredPicturesOnWikimediaCommons;
-
- public FeaturedImages(int qualityImages, int featuredPicturesOnWikimediaCommons) {
- this.qualityImages = qualityImages;
- this.featuredPicturesOnWikimediaCommons = featuredPicturesOnWikimediaCommons;
- }
-
- public int getQualityImages() {
- return qualityImages;
- }
-
- public int getFeaturedPicturesOnWikimediaCommons() {
- return featuredPicturesOnWikimediaCommons;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.java b/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.java
deleted file mode 100644
index 37fea3e2d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/FeedbackResponse.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-public class FeedbackResponse {
-
- private final String status;
- private final int uniqueUsedImages;
- private final int articlesUsingImages;
- private final int deletedUploads;
- private final FeaturedImages featuredImages;
- private final int thanksReceived;
- private final String user;
- private final int imagesEditedBySomeoneElse;
-
-
- public FeedbackResponse(String status,
- int uniqueUsedImages,
- int articlesUsingImages,
- int deletedUploads,
- FeaturedImages featuredImages,
- int thanksReceived,
- String user,
- int imagesEditedBySomeoneElse) {
- this.status = status;
- this.uniqueUsedImages = uniqueUsedImages;
- this.articlesUsingImages = articlesUsingImages;
- this.deletedUploads = deletedUploads;
- this.featuredImages = featuredImages;
- this.thanksReceived = thanksReceived;
- this.user = user;
- this.imagesEditedBySomeoneElse = imagesEditedBySomeoneElse;
- }
-
- public String getStatus() {
- return status;
- }
-
- public int getUniqueUsedImages() {
- return uniqueUsedImages;
- }
-
- public int getArticlesUsingImages() {
- return articlesUsingImages;
- }
-
- public int getDeletedUploads() {
- return deletedUploads;
- }
-
- public FeaturedImages getFeaturedImages() {
- return featuredImages;
- }
-
- public int getThanksReceived() {
- return thanksReceived;
- }
-
- public String getUser() {
- return user;
- }
-
- public int getImagesEditedBySomeoneElse() {
- return imagesEditedBySomeoneElse;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.java b/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.java
deleted file mode 100644
index e0f84bbee..000000000
--- a/app/src/main/java/fr/free/nrw/commons/achievements/LevelController.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package fr.free.nrw.commons.achievements;
-
-import android.util.Log;
-
-import fr.free.nrw.commons.R;
-
-/**
- * calculates the level of the user
- */
-public class LevelController {
-
- public LevelInfo level;
- public enum LevelInfo{
- LEVEL_1(1, R.style.LevelOne, 5, 20, 85),
- LEVEL_2(2, R.style.LevelTwo, 10, 30, 86),
- LEVEL_3(3, R.style.LevelThree, 15,40, 87),
- LEVEL_4(4, R.style.LevelFour,20,50, 88),
- LEVEL_5(5, R.style.LevelFive, 25, 60, 89),
- LEVEL_6(6,R.style.LevelOne,30,70, 90),
- LEVEL_7(7, R.style.LevelTwo, 40, 80, 90),
- LEVEL_8(8, R.style.LevelThree, 45, 90, 90),
- LEVEL_9(9, R.style.LevelFour, 50, 100, 90),
- LEVEL_10(10, R.style.LevelFive, 55, 110, 90),
- LEVEL_11(11,R.style.LevelOne, 60, 120, 90),
- LEVEL_12(12,R.style.LevelTwo,65 , 130, 90),
- LEVEL_13(13,R.style.LevelThree, 70, 140, 90),
- LEVEL_14(14,R.style.LevelFour, 75 , 150, 90),
- LEVEL_15(15,R.style.LevelFive, 80, 160, 90);
-
- private int levelNumber;
- private int levelStyle;
- private int maxUniqueImages;
- private int maxUploadCount;
- private int minNonRevertPercentage;
-
- LevelInfo(int levelNumber,
- int levelStyle,
- int maxUniqueImages,
- int maxUploadCount,
- int minNonRevertPercentage) {
- this.levelNumber = levelNumber;
- this.levelStyle = levelStyle;
- this.maxUniqueImages = maxUniqueImages;
- this.maxUploadCount = maxUploadCount;
- this.minNonRevertPercentage = minNonRevertPercentage;
- }
-
- public static LevelInfo from(int imagesUploaded,
- int uniqueImagesUsed,
- int nonRevertRate) {
- LevelInfo level = LEVEL_15;
-
- for (LevelInfo levelInfo : LevelInfo.values()) {
- if (imagesUploaded < levelInfo.maxUploadCount
- || uniqueImagesUsed < levelInfo.maxUniqueImages
- || nonRevertRate < levelInfo.minNonRevertPercentage ) {
- level = levelInfo;
- return level;
- }
- }
- return level;
- }
-
- public int getLevelStyle() {
- return levelStyle;
- }
-
- public int getLevelNumber() {
- return levelNumber;
- }
-
- public int getMaxUniqueImages() {
- return maxUniqueImages;
- }
-
- public int getMaxUploadCount() {
- return maxUploadCount;
- }
-
- public int getMinNonRevertPercentage(){
- return minNonRevertPercentage;
- }
- }
-
-}
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.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
deleted file mode 100644
index 42cfba2bf..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.content.Context;
-import android.support.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-import timber.log.Timber;
-
-public class AccountUtil {
-
- public static final String AUTH_COOKIE = "authCookie";
- public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
- private final Context context;
-
- public AccountUtil(Context context) {
- this.context = context;
- }
-
- /**
- * @return Account|null
- */
- @Nullable
- public static Account account(Context context) {
- try {
- Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- if (accounts.length > 0) {
- return accounts[0];
- }
- } catch (SecurityException e) {
- Timber.e(e);
- }
- return null;
- }
-
- @Nullable
- public static String getUserName(Context context) {
- Account account = account(context);
- return account == null ? null : account.name;
- }
-
- @Nullable
- public static String getPassword(Context context) {
- Account account = account(context);
- return account == null ? null : accountManager(context).getPassword(account);
- }
-
- private static AccountManager accountManager(Context context) {
- return AccountManager.get(context);
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
new file mode 100644
index 000000000..aa86cd0d8
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
@@ -0,0 +1,24 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import timber.log.Timber
+
+const val AUTH_TOKEN_TYPE: String = "CommonsAndroid"
+
+fun getUserName(context: Context): String? {
+ return account(context)?.name
+}
+
+@VisibleForTesting
+fun account(context: Context): Account? = try {
+ val accountManager = AccountManager.get(context)
+ val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
+ if (accounts.isNotEmpty()) accounts[0] else null
+} catch (e: SecurityException) {
+ Timber.e(e)
+ null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
deleted file mode 100644
index 611cb7975..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.os.Bundle;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Observable;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-
-import static fr.free.nrw.commons.auth.AccountUtil.AUTH_COOKIE;
-
-public abstract class AuthenticatedActivity extends NavigationBaseActivity {
-
- @Inject SessionManager sessionManager;
- @Inject
- MediaWikiApi mediaWikiApi;
- private String authCookie;
-
- protected void requestAuthToken() {
- if (authCookie != null) {
- onAuthCookieAcquired(authCookie);
- return;
- }
- authCookie = sessionManager.getAuthCookie();
- if (authCookie != null) {
- onAuthCookieAcquired(authCookie);
- }
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- if (savedInstanceState != null) {
- authCookie = savedInstanceState.getString(AUTH_COOKIE);
- }
-
- showBlockStatus();
- }
-
- @Override
- protected void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putString(AUTH_COOKIE, authCookie);
- }
-
- protected abstract void onAuthCookieAcquired(String authCookie);
-
- protected abstract void onAuthFailure();
-
- /**
- * Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
- * is created to notify the user
- */
- protected void showBlockStatus()
- {
- Observable.fromCallable(() -> mediaWikiApi.isUserBlockedFromCommons())
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .filter(result -> result)
- .subscribe(result -> {
- ViewUtil.showSnackbar(findViewById(android.R.id.content), R.string.block_notification);
- }
- );
- }
-}
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 9cdd93352..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
+++ /dev/null
@@ -1,446 +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.ProgressDialog;
-import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.Uri;
-import android.os.Bundle;
-import android.support.annotation.ColorRes;
-import android.support.annotation.NonNull;
-import android.support.annotation.StringRes;
-import android.support.design.widget.TextInputLayout;
-import android.support.v4.app.NavUtils;
-import android.support.v4.content.ContextCompat;
-import android.support.v7.app.AppCompatDelegate;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import java.io.IOException;
-import java.util.Locale;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.PageTitle;
-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.di.ApplicationlessInjection;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-import fr.free.nrw.commons.ui.widget.HtmlTextView;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Observable;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.KeyEvent.KEYCODE_ENTER;
-import static android.view.View.VISIBLE;
-import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
-import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
-
-public class LoginActivity extends AccountAuthenticatorActivity {
-
- public static final String PARAM_USERNAME = "fr.free.nrw.commons.login.username";
-
- @Inject MediaWikiApi mwApi;
- @Inject SessionManager sessionManager;
- @Inject @Named("application_preferences") SharedPreferences prefs;
- @Inject @Named("default_preferences") SharedPreferences defaultPrefs;
-
- @BindView(R.id.loginButton) Button loginButton;
- @BindView(R.id.signupButton) Button signupButton;
- @BindView(R.id.loginUsername) EditText usernameEdit;
- @BindView(R.id.loginPassword) EditText passwordEdit;
- @BindView(R.id.loginTwoFactor) EditText twoFactorEdit;
- @BindView(R.id.error_message_container) ViewGroup errorMessageContainer;
- @BindView(R.id.error_message) TextView errorMessage;
- @BindView(R.id.login_credentials) TextView loginCredentials;
- @BindView(R.id.two_factor_container) TextInputLayout twoFactorContainer;
- @BindView(R.id.forgotPassword) HtmlTextView forgotPasswordText;
-
- ProgressDialog progressDialog;
- private AppCompatDelegate delegate;
- private LoginTextWatcher textWatcher = new LoginTextWatcher();
-
- private Boolean loginCurrentlyInProgress = false;
- private Boolean errorMessageShown = false;
- private String resultantError;
- private static final String RESULTANT_ERROR = "resultantError";
- private static final String ERROR_MESSAGE_SHOWN = "errorMessageShown";
- private static final String LOGING_IN = "logingIn";
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- setTheme(Utils.isDarkTheme(this) ? R.style.DarkAppTheme : R.style.LightAppTheme);
- getDelegate().installViewFactory();
- getDelegate().onCreate(savedInstanceState);
-
- super.onCreate(savedInstanceState);
- ApplicationlessInjection
- .getInstance(this.getApplicationContext())
- .getCommonsApplicationComponent()
- .inject(this);
-
- setContentView(R.layout.activity_login);
-
- ButterKnife.bind(this);
-
- usernameEdit.addTextChangedListener(textWatcher);
- usernameEdit.setOnFocusChangeListener((v, hasFocus) -> {
- if (!hasFocus) {
- ViewUtil.hideKeyboard(v);
- }
- });
-
- passwordEdit.addTextChangedListener(textWatcher);
- passwordEdit.setOnFocusChangeListener((v, hasFocus) -> {
- if (!hasFocus) {
- ViewUtil.hideKeyboard(v);
- }
- });
-
- twoFactorEdit.addTextChangedListener(textWatcher);
- passwordEdit.setOnEditorActionListener(newLoginInputActionListener());
-
- loginButton.setOnClickListener(view -> performLogin());
- signupButton.setOnClickListener(view -> signUp());
-
- forgotPasswordText.setOnClickListener(view -> forgotPassword());
-
- if(BuildConfig.FLAVOR.equals("beta")){
- loginCredentials.setText(getString(R.string.login_credential));
- } else {
- loginCredentials.setVisibility(View.GONE);
- }
- }
-
- private void forgotPassword() {
- Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
- }
-
- @OnClick(R.id.about_privacy_policy)
- void onPrivacyPolicyClicked() {
- Utils.handleWebUrl(this,Uri.parse("https://github.com/commons-app/apps-android-commons/wiki/Privacy-policy\\"));
- }
-
- @Override
- protected void onPostCreate(Bundle savedInstanceState) {
- super.onPostCreate(savedInstanceState);
- getDelegate().onPostCreate(savedInstanceState);
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- if (prefs.getBoolean("firstrun", true)) {
- WelcomeActivity.startYourself(this);
- prefs.edit().putBoolean("firstrun", false).apply();
- }
-
- if (sessionManager.getCurrentAccount() != null
- && sessionManager.isUserLoggedIn()
- && sessionManager.getCachedAuthCookie() != null) {
- sessionManager.revalidateAuthToken();
- startMainActivity();
- }
- }
-
- @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 (progressDialog != null && progressDialog.isShowing()) {
- progressDialog.dismiss();
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- usernameEdit.removeTextChangedListener(textWatcher);
- passwordEdit.removeTextChangedListener(textWatcher);
- twoFactorEdit.removeTextChangedListener(textWatcher);
- delegate.onDestroy();
- super.onDestroy();
- }
-
- private void performLogin() {
- loginCurrentlyInProgress = true;
- Timber.d("Login to start!");
- final String username = canonicializeUsername(usernameEdit.getText().toString());
- final String password = passwordEdit.getText().toString();
- String twoFactorCode = twoFactorEdit.getText().toString();
-
- showLoggingProgressBar();
- Observable.fromCallable(() -> login(username, password, twoFactorCode))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(result -> handleLogin(username, password, result));
- }
-
- private String login(String username, String password, String twoFactorCode) {
- try {
- if (twoFactorCode.isEmpty()) {
- return mwApi.login(username, password);
- } else {
- return mwApi.login(username, password, twoFactorCode);
- }
- } catch (IOException e) {
- // Do something better!
- return "NetworkFailure";
- }
- }
-
- private void handleLogin(String username, String password, String result) {
- Timber.d("Login done!");
- if (result.equals("PASS")) {
- handlePassResult(username, password);
- } else {
- loginCurrentlyInProgress = false;
- errorMessageShown = true;
- resultantError = result;
- handleOtherResults(result);
- }
- }
-
- private void showLoggingProgressBar() {
- progressDialog = new ProgressDialog(this);
- progressDialog.setIndeterminate(true);
- progressDialog.setTitle(getString(R.string.logging_in_title));
- progressDialog.setMessage(getString(R.string.logging_in_message));
- progressDialog.setCanceledOnTouchOutside(false);
- progressDialog.show();
- }
-
- private void handlePassResult(String username, String password) {
- showSuccessAndDismissDialog();
- requestAuthToken();
- AccountAuthenticatorResponse response = null;
-
- Bundle extras = getIntent().getExtras();
- if (extras != null) {
- Timber.d("Bundle of extras: %s", extras);
- response = extras.getParcelable(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE);
- if (response != null) {
- Bundle authResult = new Bundle();
- authResult.putString(AccountManager.KEY_ACCOUNT_NAME, username);
- authResult.putString(AccountManager.KEY_ACCOUNT_TYPE, BuildConfig.ACCOUNT_TYPE);
- response.onResult(authResult);
- }
- }
-
- sessionManager.createAccount(response, username, password);
- startMainActivity();
- }
-
- protected void requestAuthToken() {
- AccountManager accountManager = AccountManager.get(this);
- Account curAccount = sessionManager.getCurrentAccount();
- if (curAccount != null) {
- accountManager.setAuthToken(curAccount, AUTH_TOKEN_TYPE, mwApi.getAuthCookie());
- }
- }
-
- /**
- * Match known failure message codes and provide messages.
- *
- * @param result String
- */
- private void handleOtherResults(String result) {
- if (result.equals("NetworkFailure")) {
- // Matches NetworkFailure which is created by the doInBackground method
- showMessageAndCancelDialog(R.string.login_failed_network);
- } else if (result.toLowerCase(Locale.getDefault()).contains("nosuchuser".toLowerCase()) || result.toLowerCase().contains("noname".toLowerCase())) {
- // Matches nosuchuser, nosuchusershort, noname
- showMessageAndCancelDialog(R.string.login_failed_wrong_credentials);
- emptySensitiveEditFields();
- } else if (result.toLowerCase(Locale.getDefault()).contains("wrongpassword".toLowerCase())) {
- // Matches wrongpassword, wrongpasswordempty
- showMessageAndCancelDialog(R.string.login_failed_wrong_credentials);
- emptySensitiveEditFields();
- } else if (result.toLowerCase(Locale.getDefault()).contains("throttle".toLowerCase())) {
- // Matches unknown throttle error codes
- showMessageAndCancelDialog(R.string.login_failed_throttled);
- } else if (result.toLowerCase(Locale.getDefault()).contains("userblocked".toLowerCase())) {
- // Matches login-userblocked
- showMessageAndCancelDialog(R.string.login_failed_blocked);
- } else if (result.equals("2FA")) {
- askUserForTwoFactorAuth();
- } else {
- // Occurs with unhandled login failure codes
- Timber.d("Login failed with reason: %s", result);
- showMessageAndCancelDialog(R.string.login_failed_generic);
- }
- }
-
- /**
- * Because Mediawiki is upercase-first-char-then-case-sensitive :)
- * @param username String
- * @return String canonicial username
- */
- private String canonicializeUsername(String username) {
- return new PageTitle(username).getText();
- }
-
- @Override
- protected void onStart() {
- super.onStart();
- delegate.onStart();
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- delegate.onStop();
- }
-
- @Override
- protected void onPostResume() {
- super.onPostResume();
- getDelegate().onPostResume();
- }
-
- @Override
- public void setContentView(View view, ViewGroup.LayoutParams params) {
- getDelegate().setContentView(view, params);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case android.R.id.home:
- NavUtils.navigateUpFromSameTask(this);
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- @Override
- @NonNull
- public MenuInflater getMenuInflater() {
- return getDelegate().getMenuInflater();
- }
-
- @Override
- protected void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putBoolean(LOGING_IN, loginCurrentlyInProgress);
- outState.putBoolean(ERROR_MESSAGE_SHOWN, errorMessageShown);
- outState.putString(RESULTANT_ERROR, resultantError);
- }
-
- @Override
- protected void onRestoreInstanceState(Bundle savedInstanceState) {
- super.onRestoreInstanceState(savedInstanceState);
- loginCurrentlyInProgress = savedInstanceState.getBoolean(LOGING_IN, false);
- errorMessageShown = savedInstanceState.getBoolean(ERROR_MESSAGE_SHOWN, false);
- if(loginCurrentlyInProgress){
- performLogin();
- }
- if(errorMessageShown){
- resultantError = savedInstanceState.getString(RESULTANT_ERROR);
- handleOtherResults(resultantError);
- }
- }
-
- public void askUserForTwoFactorAuth() {
- progressDialog.dismiss();
- twoFactorContainer.setVisibility(VISIBLE);
- twoFactorEdit.setVisibility(VISIBLE);
- showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
- }
-
- public void showMessageAndCancelDialog(@StringRes int resId) {
- showMessage(resId, R.color.secondaryDarkColor);
- if(progressDialog != null){
- progressDialog.cancel();
- }
- }
-
- public void showSuccessAndDismissDialog() {
- showMessage(R.string.login_success, R.color.primaryDarkColor);
- progressDialog.dismiss();
- }
-
- public void emptySensitiveEditFields() {
- passwordEdit.setText("");
- twoFactorEdit.setText("");
- }
-
- public void startMainActivity() {
- NavigationBaseActivity.startActivityWithFlags(this, ContributionsActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP);
- finish();
- }
-
- private void signUp() {
- Intent intent = new Intent(this, SignupActivity.class);
- startActivity(intent);
- }
-
- private TextView.OnEditorActionListener newLoginInputActionListener() {
- return (textView, actionId, keyEvent) -> {
- if (loginButton.isEnabled()) {
- if (actionId == IME_ACTION_DONE) {
- performLogin();
- return true;
- } else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
- performLogin();
- return true;
- }
- }
- return false;
- };
- }
-
- private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
- errorMessage.setText(getString(resId));
- errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
- errorMessageContainer.setVisibility(VISIBLE);
- }
-
- private AppCompatDelegate getDelegate() {
- if (delegate == null) {
- delegate = AppCompatDelegate.create(this, null);
- }
- return delegate;
- }
-
- private class LoginTextWatcher implements TextWatcher {
- @Override
- public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
- }
-
- @Override
- public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
- }
-
- @Override
- public void afterTextChanged(Editable editable) {
- boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
- && (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
- loginButton.setEnabled(enabled);
- }
- }
-
- public static void startYourself(Context context) {
- Intent intent = new Intent(context, LoginActivity.class);
- context.startActivity(intent);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
new file mode 100644
index 000000000..0c9901b56
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
@@ -0,0 +1,489 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AccountAuthenticatorActivity
+import android.app.ProgressDialog
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.TextView
+import androidx.annotation.ColorRes
+import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.app.NavUtils
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.databinding.ActivityLoginBinding
+import fr.free.nrw.commons.di.ApplicationlessInjection
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.AbstractTextWatcher
+import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
+import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
+import fr.free.nrw.commons.utils.SystemThemeUtils
+import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
+import fr.free.nrw.commons.utils.handleKeyboardInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import io.reactivex.disposables.CompositeDisposable
+import timber.log.Timber
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Named
+
+class LoginActivity : AccountAuthenticatorActivity() {
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ @field:Named("default_preferences")
+ lateinit var applicationKvStore: JsonKvStore
+
+ @Inject
+ lateinit var loginClient: LoginClient
+
+ @Inject
+ lateinit var systemThemeUtils: SystemThemeUtils
+
+ private var binding: ActivityLoginBinding? = null
+ private var progressDialog: ProgressDialog? = null
+ private val textWatcher = AbstractTextWatcher(::onTextChanged)
+ private val compositeDisposable = CompositeDisposable()
+ private val delegate: AppCompatDelegate by lazy {
+ AppCompatDelegate.create(this, null)
+ }
+ private var lastLoginResult: LoginResult? = null
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ ApplicationlessInjection
+ .getInstance(this.applicationContext)
+ .commonsApplicationComponent
+ .inject(this)
+
+ val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
+ setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
+ delegate.installViewFactory()
+ delegate.onCreate(savedInstanceState)
+
+ WindowCompat.getInsetsController(window, window.decorView)
+ .isAppearanceLightStatusBars = !isDarkTheme
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ applyEdgeToEdgeAllInsets(binding!!.root)
+ binding!!.root.handleKeyboardInsets()
+ with(binding!!) {
+ setContentView(root)
+
+ loginUsername.addTextChangedListener(textWatcher)
+ loginPassword.addTextChangedListener(textWatcher)
+ loginTwoFactor.addTextChangedListener(textWatcher)
+
+ skipLogin.setOnClickListener { skipLogin() }
+ forgotPassword.setOnClickListener { forgotPassword() }
+ aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
+ signUpButton.setOnClickListener { signUp() }
+ loginButton.setOnClickListener { performLogin() }
+ loginPassword.setOnEditorActionListener { textView, actionId, keyEvent ->
+ if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+ if (actionId == EditorInfo.IME_ACTION_NEXT && lastLoginResult != null) {
+ askUserForTwoFactorAuthWithKeyboard()
+ true
+ } else {
+ performLogin()
+ true
+ }
+ } else {
+ false
+ }
+ }
+
+ loginPassword.onFocusChangeListener =
+ View.OnFocusChangeListener(::onPasswordFocusChanged)
+
+ if (isBetaFlavour) {
+ loginCredentials.text = getString(R.string.login_credential)
+ } else {
+ loginCredentials.visibility = View.GONE
+ }
+
+ intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
+ showMessage(it, R.color.secondaryDarkColor)
+ }
+
+ intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
+ loginUsername.setText(it)
+ }
+ }
+ }
+
+ @VisibleForTesting
+ fun askUserForTwoFactorAuthWithKeyboard() {
+ if (binding == null) {
+ Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuthWithKeyboard")
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding!!.root)
+ }
+ progressDialog!!.dismiss()
+ if (binding != null) {
+ with(binding!!) {
+ twoFactorContainer.visibility = View.VISIBLE
+ twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+ loginTwoFactor.visibility = View.VISIBLE
+ loginTwoFactor.requestFocus()
+
+ val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.showSoftInput(loginTwoFactor, InputMethodManager.SHOW_IMPLICIT)
+
+ loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+ performLogin()
+ true
+ } else {
+ false
+ }
+ }
+ }
+ } else {
+ Timber.e("Binding is null in askUserForTwoFactorAuthWithKeyboard after reinitialization attempt")
+ }
+ showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+ }
+ override fun onPostCreate(savedInstanceState: Bundle?) {
+ super.onPostCreate(savedInstanceState)
+ delegate.onPostCreate(savedInstanceState)
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
+ applicationKvStore.putBoolean("login_skipped", false)
+ startMainActivity()
+ }
+
+ if (applicationKvStore.getBoolean("login_skipped", false)) {
+ performSkipLogin()
+ }
+ }
+
+ override fun onDestroy() {
+ compositeDisposable.clear()
+ try {
+ // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
+ if (progressDialog?.isShowing == true) {
+ progressDialog!!.dismiss()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ with(binding!!) {
+ loginUsername.removeTextChangedListener(textWatcher)
+ loginPassword.removeTextChangedListener(textWatcher)
+ loginTwoFactor.removeTextChangedListener(textWatcher)
+ }
+ delegate.onDestroy()
+ loginClient.cancel()
+ binding = null
+ super.onDestroy()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ delegate.onStart()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ delegate.onStop()
+ }
+
+ override fun onPostResume() {
+ super.onPostResume()
+ delegate.onPostResume()
+ }
+
+ override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
+ delegate.setContentView(view, params)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ NavUtils.navigateUpFromSameTask(this)
+ return true
+ }
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ // if progressDialog is visible during the configuration change then store state as true else false so that
+ // we maintain visibility of progressDialog after configuration change
+ if (progressDialog != null && progressDialog!!.isShowing) {
+ outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
+ } else {
+ outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
+ }
+ outState.putString(
+ SAVE_ERROR_MESSAGE,
+ binding!!.errorMessage.text.toString()
+ ) //Save the errorMessage
+ outState.putString(
+ SAVE_USERNAME,
+ binding!!.loginUsername.text.toString()
+ ) // Save the username
+ outState.putString(
+ SAVE_PASSWORD,
+ binding!!.loginPassword.text.toString()
+ ) // Save the password
+ }
+
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+ binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
+ binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
+ if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
+ performLogin()
+ }
+ val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
+ if (sessionManager.isUserLoggedIn) {
+ showMessage(R.string.login_success, R.color.primaryDarkColor)
+ } else {
+ showMessage(errorMessage, R.color.secondaryDarkColor)
+ }
+ }
+
+ /**
+ * Hides the keyboard if the user's focus is not on the password (hasFocus is false).
+ * @param view The keyboard
+ * @param hasFocus Set to true if the keyboard has focus
+ */
+ private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
+ if (!hasFocus) {
+ hideKeyboard(view)
+ }
+ }
+
+ private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
+ if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
+ performLogin()
+ true
+ } else false
+
+ private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
+ actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
+
+ private fun skipLogin() {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.skip_login_title)
+ .setMessage(R.string.skip_login_message)
+ .setCancelable(false)
+ .setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
+ dialog.cancel()
+ performSkipLogin()
+ }
+ .setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
+ dialog.cancel()
+ }
+ .show()
+ }
+
+ private fun forgotPassword() =
+ handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
+
+ private fun onPrivacyPolicyClicked() =
+ handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
+
+ private fun signUp() =
+ startActivity(Intent(this, SignupActivity::class.java))
+
+ @VisibleForTesting
+ fun performLogin() {
+ Timber.d("Login to start!")
+ val username = binding!!.loginUsername.text.toString()
+ val password = binding!!.loginPassword.text.toString()
+ val twoFactorCode = binding!!.loginTwoFactor.text.toString()
+
+ showLoggingProgressBar()
+ loginClient.doLogin(username,
+ password,
+ lastLoginResult,
+ twoFactorCode,
+ Locale.getDefault().language,
+ object : LoginCallback {
+ override fun success(loginResult: LoginResult) = runOnUiThread {
+ Timber.d("Login Success")
+ progressDialog!!.dismiss()
+ onLoginSuccess(loginResult)
+ }
+
+ override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+ Timber.d("Requesting 2FA prompt")
+ progressDialog!!.dismiss()
+ lastLoginResult = loginResult
+ askUserForTwoFactorAuthWithKeyboard()
+ }
+
+ override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
+ Timber.d("Requesting email auth prompt")
+ progressDialog!!.dismiss()
+ lastLoginResult = loginResult
+ askUserForTwoFactorAuthWithKeyboard()
+ }
+
+ override fun passwordResetPrompt(token: String?) = runOnUiThread {
+ Timber.d("Showing password reset prompt")
+ progressDialog!!.dismiss()
+ showPasswordResetPrompt()
+ }
+
+ override fun error(caught: Throwable) = runOnUiThread {
+ Timber.e(caught)
+ progressDialog!!.dismiss()
+ showMessageAndCancelDialog(caught.localizedMessage ?: "")
+ }
+ }
+ )
+ }
+
+ private fun showPasswordResetPrompt() =
+ showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
+
+ /**
+ * This function is called when user skips the login.
+ * It redirects the user to Explore Activity.
+ */
+ private fun performSkipLogin() {
+ applicationKvStore.putBoolean("login_skipped", true)
+ MainActivity.startYourself(this)
+ finish()
+ }
+
+ private fun showLoggingProgressBar() {
+ progressDialog = ProgressDialog(this).apply {
+ isIndeterminate = true
+ setTitle(getString(R.string.logging_in_title))
+ setMessage(getString(R.string.logging_in_message))
+ setCancelable(false)
+ }
+ progressDialog!!.show()
+ }
+
+ private fun onLoginSuccess(loginResult: LoginResult) {
+ compositeDisposable.clear()
+ sessionManager.setUserLoggedIn(true)
+ sessionManager.updateAccount(loginResult)
+ progressDialog!!.dismiss()
+ showSuccessAndDismissDialog()
+ startMainActivity()
+ }
+
+ override fun getMenuInflater(): MenuInflater =
+ delegate.menuInflater
+
+ @VisibleForTesting
+ fun askUserForTwoFactorAuth() {
+ if (binding == null) {
+ Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuth")
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding!!.root)
+ }
+ progressDialog!!.dismiss()
+ if (binding != null) {
+ with(binding!!) {
+ twoFactorContainer.visibility = View.VISIBLE
+ twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
+ loginTwoFactor.visibility = View.VISIBLE
+ loginTwoFactor.requestFocus()
+
+ loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
+ performLogin()
+ true
+ } else {
+ false
+ }
+ }
+ }
+ } else {
+ Timber.e("Binding is null in askUserForTwoFactorAuth after reinitialization attempt")
+ }
+ val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
+ showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(@StringRes resId: Int) {
+ showMessage(resId, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(error: String) {
+ showMessage(error, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showSuccessAndDismissDialog() {
+ showMessage(R.string.login_success, R.color.primaryDarkColor)
+ progressDialog!!.dismiss()
+ }
+
+ @VisibleForTesting
+ fun startMainActivity() {
+ startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ finish()
+ }
+
+ private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = getString(resId)
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = message
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun onTextChanged(text: String) {
+ val enabled =
+ binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
+ (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
+ binding!!.loginButton.isEnabled = enabled
+ }
+
+ companion object {
+ fun startYourself(context: Context) =
+ context.startActivity(Intent(context, LoginActivity::class.java))
+
+ const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
+ const val SAVE_ERROR_MESSAGE: String = "errorMessage"
+ const val SAVE_USERNAME: String = "username"
+ const val SAVE_PASSWORD: String = "password"
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
deleted file mode 100644
index e8745e25b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
+++ /dev/null
@@ -1,171 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.os.Bundle;
-
-import javax.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import io.reactivex.Completable;
-import io.reactivex.Observable;
-import timber.log.Timber;
-
-import static android.accounts.AccountManager.ERROR_CODE_REMOTE_EXCEPTION;
-import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
-import static android.accounts.AccountManager.KEY_ACCOUNT_TYPE;
-
-/**
- * Manage the current logged in user session.
- */
-public class SessionManager {
- private final Context context;
- private final MediaWikiApi mediaWikiApi;
- private Account currentAccount; // Unlike a savings account... ;-)
- private SharedPreferences sharedPreferences;
-
-
- public SessionManager(Context context,
- MediaWikiApi mediaWikiApi,
- SharedPreferences sharedPreferences) {
- this.context = context;
- this.mediaWikiApi = mediaWikiApi;
- this.currentAccount = null;
- this.sharedPreferences = sharedPreferences;
- }
-
- /**
- * Creata a new account
- *
- * @param response
- * @param username
- * @param password
- */
- public void createAccount(@Nullable AccountAuthenticatorResponse response,
- String username, String password) {
-
- Account account = new Account(username, BuildConfig.ACCOUNT_TYPE);
- boolean created = accountManager().addAccountExplicitly(account, password, null);
-
- Timber.d("account creation " + (created ? "successful" : "failure"));
-
- if (created) {
- if (response != null) {
- Bundle bundle = new Bundle();
- bundle.putString(KEY_ACCOUNT_NAME, username);
- bundle.putString(KEY_ACCOUNT_TYPE, BuildConfig.ACCOUNT_TYPE);
-
-
- response.onResult(bundle);
- }
-
- } else {
- if (response != null) {
- response.onError(ERROR_CODE_REMOTE_EXCEPTION, "");
- }
- Timber.d("account creation failure");
- }
-
- // FIXME: If the user turns it off, it shouldn't be auto turned back on
- ContentResolver.setSyncAutomatically(account, BuildConfig.CONTRIBUTION_AUTHORITY, true); // Enable sync by default!
- ContentResolver.setSyncAutomatically(account, BuildConfig.MODIFICATION_AUTHORITY, true); // Enable sync by default!
- }
-
- /**
- * @return Account|null
- */
- @Nullable
- public Account getCurrentAccount() {
- if (currentAccount == null) {
- AccountManager accountManager = AccountManager.get(context);
- Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- if (allAccounts.length != 0) {
- currentAccount = allAccounts[0];
- }
- }
- return currentAccount;
- }
-
- @Nullable
- public String getUserName() {
- Account account = getCurrentAccount();
- return account == null ? null : account.name;
- }
-
- @Nullable
- public String getPassword() {
- Account account = getCurrentAccount();
- return account == null ? null : accountManager().getPassword(account);
- }
-
- private AccountManager accountManager() {
- return AccountManager.get(context);
- }
-
- public Boolean revalidateAuthToken() {
- AccountManager accountManager = AccountManager.get(context);
- Account curAccount = getCurrentAccount();
-
- if (curAccount == null) {
- return false; // This should never happen
- }
-
- accountManager.invalidateAuthToken(BuildConfig.ACCOUNT_TYPE, null);
- String authCookie = getAuthCookie();
-
- if (authCookie == null) {
- return false;
- }
-
- mediaWikiApi.setAuthCookie(authCookie);
- return true;
- }
-
- public String getAuthCookie() {
- if (!isUserLoggedIn()) {
- Timber.e("User is not logged in");
- return null;
- } else {
- String authCookie = getCachedAuthCookie();
- if (authCookie == null) {
- Timber.e("Auth cookie is null even after login");
- }
- return authCookie;
- }
- }
-
- public String getCachedAuthCookie() {
- return sharedPreferences.getString("getAuthCookie", null);
- }
-
- public boolean isUserLoggedIn() {
- return sharedPreferences.getBoolean("isUserLoggedIn", false);
- }
-
- public void forceLogin(Context context) {
- if (context != null) {
- LoginActivity.startYourself(context);
- }
- }
-
- /**
- * 1. Clears existing accounts from account manager
- * 2. Calls MediaWikiApi's logout function to clear cookies
- * @return
- */
- public Completable logout() {
- AccountManager accountManager = AccountManager.get(context);
- Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- return Completable.fromObservable(Observable.fromArray(allAccounts)
- .map(a -> accountManager.removeAccount(a, null, null).getResult()))
- .doOnComplete(() -> {
- mediaWikiApi.logout();
- currentAccount = null;
- });
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
new file mode 100644
index 000000000..c9eb7d2f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
@@ -0,0 +1,95 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import android.os.Build
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.reactivex.Completable
+import io.reactivex.Observable
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Manage the current logged in user session.
+ */
+@Singleton
+class SessionManager @Inject constructor(
+ private val context: Context,
+ @param:Named("default_preferences") private val defaultKvStore: JsonKvStore
+) {
+ private val accountManager: AccountManager get() = AccountManager.get(context)
+
+ private var _currentAccount: Account? = null // Unlike a savings account... ;-)
+ val currentAccount: Account? get() {
+ if (_currentAccount == null) {
+ val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
+ if (allAccounts.isNotEmpty()) {
+ _currentAccount = allAccounts[0]
+ }
+ }
+ return _currentAccount
+ }
+
+ val userName: String?
+ get() = currentAccount?.name
+
+ var password: String?
+ get() = currentAccount?.let { accountManager.getPassword(it) }
+ private set(value) {
+ currentAccount?.let { accountManager.setPassword(it, value) }
+ }
+
+ val isUserLoggedIn: Boolean
+ get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
+
+ fun updateAccount(result: LoginResult) {
+ if (createAccount(result.userName!!, result.password!!)) {
+ password = result.password
+ }
+ }
+
+ fun doesAccountExist(): Boolean =
+ currentAccount != null
+
+ fun setUserLoggedIn(isLoggedIn: Boolean) =
+ defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
+
+ fun forceLogin(context: Context?) =
+ context?.let { LoginActivity.startYourself(it) }
+
+ fun getPreference(key: String): Boolean =
+ defaultKvStore.getBoolean(key)
+
+ fun logout(): Completable = Completable.fromObservable(
+ Observable.empty()
+ .doOnComplete {
+ removeAccount()
+ _currentAccount = null
+ }
+ )
+
+ private fun createAccount(userName: String, password: String): Boolean {
+ var account = currentAccount
+ if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
+ removeAccount()
+ account = Account(userName, ACCOUNT_TYPE)
+ return accountManager.addAccountExplicitly(account, password, null)
+ }
+ return true
+ }
+
+ private fun removeAccount() {
+ currentAccount?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+ accountManager.removeAccountExplicitly(it)
+ } else {
+ accountManager.removeAccount(it, null, null)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
deleted file mode 100644
index a6b66cbf6..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.os.Bundle;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.Toast;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.theme.BaseActivity;
-import timber.log.Timber;
-
-public class SignupActivity extends BaseActivity {
-
- private WebView webView;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- Timber.d("Signup Activity started");
-
- webView = new WebView(this);
- setContentView(webView);
-
- webView.setWebViewClient(new MyWebViewClient());
- WebSettings webSettings = webView.getSettings();
- /*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
- trust Wikimedia's site... right?*/
- webSettings.setJavaScriptEnabled(true);
-
- webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
- }
-
- private class MyWebViewClient extends WebViewClient {
- @Override
- public boolean shouldOverrideUrlLoading(WebView view, String url) {
- if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
- //Signup success, so clear cookies, notify user, and load LoginActivity again
- Timber.d("Overriding URL %s", url);
-
- Toast toast = Toast.makeText(SignupActivity.this,
- "Account created!", Toast.LENGTH_LONG);
- toast.show();
- // terminate on task completion.
- finish();
- return true;
- } else {
- //If user clicks any other links in the webview
- Timber.d("Not overriding URL, URL is: %s", url);
- return false;
- }
- }
- }
-
- @Override
- public void onBackPressed() {
- if (webView.canGoBack()) {
- webView.goBack();
- } else {
- super.onBackPressed();
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
new file mode 100644
index 000000000..22f557bcd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
@@ -0,0 +1,77 @@
+package fr.free.nrw.commons.auth
+
+import android.annotation.SuppressLint
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Bundle
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import timber.log.Timber
+
+class SignupActivity : BaseActivity() {
+ private var webView: WebView? = null
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Timber.d("Signup Activity started")
+
+ webView = WebView(this)
+ applyEdgeToEdgeAllInsets(webView!!)
+ with(webView!!) {
+ setContentView(this)
+ webViewClient = MyWebViewClient()
+ // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
+ // trust Wikimedia's site... right?
+ settings.javaScriptEnabled = true
+ loadUrl(BuildConfig.SIGNUP_LANDING_URL)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (webView!!.canGoBack()) {
+ webView!!.goBack()
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+ /**
+ * Known bug in androidx.appcompat library version 1.1.0 being tracked here
+ * https://issuetracker.google.com/issues/141132133
+ * App tries to put light/dark theme to webview and crashes in the process
+ * This code tries to prevent applying the theme when sdk is between api 21 to 25
+ */
+ override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
+ if (Build.VERSION.SDK_INT <= 25 &&
+ (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
+ ) return
+ super.applyOverrideConfiguration(overrideConfiguration)
+ }
+
+ private inner class MyWebViewClient : WebViewClient() {
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
+ if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
+ //Signup success, so clear cookies, notify user, and load LoginActivity again
+ Timber.d("Overriding URL %s", url)
+
+ Toast.makeText(
+ this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
+ ).show()
+
+ // terminate on task completion.
+ finish()
+ true
+ } else {
+ //If user clicks any other links in the webview
+ Timber.d("Not overriding URL, URL is: %s", url)
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
deleted file mode 100644
index 2f71a69b4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
+++ /dev/null
@@ -1,140 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.accounts.NetworkErrorException;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-
-import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
-
-public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
- private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY};
-
- @NonNull
- private final Context context;
-
- public WikiAccountAuthenticator(@NonNull Context context) {
- super(context);
- this.context = context;
- }
-
- @Override
- public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
- Bundle bundle = new Bundle();
- bundle.putString("test", "editProperties");
- return bundle;
- }
-
- @Override
- public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
- @NonNull String accountType, @Nullable String authTokenType,
- @Nullable String[] requiredFeatures, @Nullable Bundle options)
- throws NetworkErrorException {
-
- if (!supportedAccountType(accountType)) {
- Bundle bundle = new Bundle();
- bundle.putString("test", "addAccount");
- return bundle;
- }
-
- return addAccount(response);
- }
-
- @Override
- public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "confirmCredentials");
- return bundle;
- }
-
- @Override
- public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @NonNull String authTokenType,
- @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "getAuthToken");
- return bundle;
- }
-
- @Nullable
- @Override
- public String getAuthTokenLabel(@NonNull String authTokenType) {
- return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
- }
-
- @Nullable
- @Override
- public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @Nullable String authTokenType,
- @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "updateCredentials");
- return bundle;
- }
-
- @Nullable
- @Override
- public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @NonNull String[] features)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
- return bundle;
- }
-
- private boolean supportedAccountType(@Nullable String type) {
- return BuildConfig.ACCOUNT_TYPE.equals(type);
- }
-
- private Bundle addAccount(AccountAuthenticatorResponse response) {
- Intent intent = new Intent(context, LoginActivity.class);
- intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
-
- Bundle bundle = new Bundle();
- bundle.putParcelable(AccountManager.KEY_INTENT, intent);
-
- return bundle;
- }
-
- private Bundle unsupportedOperation() {
- Bundle bundle = new Bundle();
- bundle.putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION);
-
- // HACK: the docs indicate that this is a required key bit it's not displayed to the user.
- bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "");
-
- return bundle;
- }
-
- @Override
- public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
- Account account) throws NetworkErrorException {
- Bundle result = super.getAccountRemovalAllowed(response, account);
-
- if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
- && !result.containsKey(AccountManager.KEY_INTENT)) {
- boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
-
- if (allowed) {
- for (String auth : SYNC_AUTHORITIES) {
- ContentResolver.cancelSync(account, auth);
- }
- }
- }
-
- return result;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
new file mode 100644
index 000000000..367989f14
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
@@ -0,0 +1,108 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.accounts.NetworkErrorException
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import fr.free.nrw.commons.BuildConfig
+
+private val SYNC_AUTHORITIES = arrayOf(
+ BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
+)
+
+/**
+ * Handles WikiMedia commons account Authentication
+ */
+class WikiAccountAuthenticator(
+ private val context: Context
+) : AbstractAccountAuthenticator(context) {
+ /**
+ * Provides Bundle with edited Account Properties
+ */
+ override fun editProperties(
+ response: AccountAuthenticatorResponse,
+ accountType: String
+ ) = bundleOf("test" to "editProperties")
+
+ // account type not supported returns bundle without loginActivity Intent, it just contains "test" key
+ @Throws(NetworkErrorException::class)
+ override fun addAccount(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle?
+ ) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
+ addAccount(response)
+ } else {
+ bundleOf("test" to "addAccount")
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse, account: Account, options: Bundle?
+ ) = bundleOf("test" to "confirmCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String,
+ options: Bundle?
+ ) = bundleOf("test" to "getAuthToken")
+
+ override fun getAuthTokenLabel(authTokenType: String) =
+ if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
+
+ @Throws(NetworkErrorException::class)
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String?,
+ options: Bundle?
+ ) = bundleOf("test" to "updateCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse,
+ account: Account, features: Array
+ ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
+
+ /**
+ * Provides a bundle containing a Parcel
+ * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
+ */
+ private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
+ val intent = Intent(context, LoginActivity::class.java)
+ .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+ return bundleOf(AccountManager.KEY_INTENT to intent)
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun getAccountRemovalAllowed(
+ response: AccountAuthenticatorResponse?,
+ account: Account?
+ ): Bundle {
+ val result = super.getAccountRemovalAllowed(response, account)
+
+ if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
+ && !result.containsKey(AccountManager.KEY_INTENT)
+ ) {
+ val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
+
+ if (allowed) {
+ for (auth in SYNC_AUTHORITIES) {
+ ContentResolver.cancelSync(account, auth)
+ }
+ }
+ }
+
+ return result
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
deleted file mode 100644
index 826f2ceee..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.content.Intent;
-import android.os.IBinder;
-import android.support.annotation.Nullable;
-
-import fr.free.nrw.commons.di.CommonsDaggerService;
-
-public class WikiAccountAuthenticatorService extends CommonsDaggerService {
-
- @Nullable
- private AbstractAccountAuthenticator authenticator;
-
- @Override
- public void onCreate() {
- super.onCreate();
- authenticator = new WikiAccountAuthenticator(this);
- }
-
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return authenticator == null ? null : authenticator.getIBinder();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
new file mode 100644
index 000000000..852536a48
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
@@ -0,0 +1,22 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.content.Intent
+import android.os.IBinder
+import fr.free.nrw.commons.di.CommonsDaggerService
+
+/**
+ * Handles the Auth service of the App, see AndroidManifests for details
+ * (Uses Dagger 2 as injector)
+ */
+class WikiAccountAuthenticatorService : CommonsDaggerService() {
+ private var authenticator: AbstractAccountAuthenticator? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ authenticator = WikiAccountAuthenticator(this)
+ }
+
+ override fun onBind(intent: Intent): IBinder? =
+ authenticator?.iBinder
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
new file mode 100644
index 000000000..6353e54ac
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
@@ -0,0 +1,217 @@
+package fr.free.nrw.commons.auth.csrf
+
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.auth.SessionManager
+import fr.free.nrw.commons.auth.login.LoginCallback
+import fr.free.nrw.commons.auth.login.LoginClient
+import fr.free.nrw.commons.auth.login.LoginFailedException
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.Response
+import timber.log.Timber
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors.newSingleThreadExecutor
+
+class CsrfTokenClient(
+ private val sessionManager: SessionManager,
+ private val csrfTokenInterface: CsrfTokenInterface,
+ private val loginClient: LoginClient,
+ private val logoutClient: LogoutClient,
+) {
+ private var retries = 0
+ private var csrfTokenCall: Call? = null
+
+ @Throws(Throwable::class)
+ fun getTokenBlocking(): String {
+ var token = ""
+ val userName = sessionManager.userName ?: ""
+ val password = sessionManager.password ?: ""
+
+ for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) {
+ try {
+ if (retry > 0) {
+ // Log in explicitly
+ loginClient.loginBlocking(userName, password)
+ }
+
+ // Get CSRFToken response off the main thread.
+ val response =
+ newSingleThreadExecutor()
+ .submit(
+ Callable {
+ csrfTokenInterface.getCsrfTokenCall().execute()
+ },
+ ).get()
+
+ if (response
+ .body()
+ ?.query()
+ ?.csrfToken()
+ .isNullOrEmpty()
+ ) {
+ continue
+ }
+
+ token = response.body()!!.query()!!.csrfToken()!!
+ if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ break
+ } catch (e: LoginFailedException) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ } catch (t: Throwable) {
+ Timber.w(t)
+ }
+ }
+
+ if (token.isEmpty() || token == ANON_TOKEN) {
+ throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ return token
+ }
+
+ @VisibleForTesting
+ fun request(
+ service: CsrfTokenInterface,
+ cb: Callback,
+ ): Call =
+ requestToken(
+ service,
+ object : Callback {
+ override fun success(token: String?) {
+ if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
+ retryWithLogin(cb) {
+ InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
+ }
+ } else {
+ cb.success(token)
+ }
+ }
+
+ override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught }
+
+ override fun twoFactorPrompt() = cb.twoFactorPrompt()
+
+ override fun emailAuthPrompt() = cb.emailAuthPrompt()
+ },
+ )
+
+ @VisibleForTesting
+ fun requestToken(
+ service: CsrfTokenInterface,
+ cb: Callback,
+ ): Call {
+ val call = service.getCsrfTokenCall()
+ call.enqueue(
+ object : retrofit2.Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.success(response.body()!!.query()!!.csrfToken())
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.failure(t)
+ }
+ },
+ )
+ return call
+ }
+
+ private fun retryWithLogin(
+ callback: Callback,
+ caught: () -> Throwable?,
+ ) {
+ val userName = sessionManager.userName
+ val password = sessionManager.password
+ if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) {
+ retries++
+ logoutClient.logout()
+ login(userName, password, callback) {
+ Timber.i("retrying...")
+ cancel()
+ csrfTokenCall = request(csrfTokenInterface, callback)
+ }
+ } else {
+ callback.failure(caught())
+ }
+ }
+
+ private fun login(
+ username: String,
+ password: String,
+ callback: Callback,
+ retryCallback: () -> Unit,
+ ) = loginClient.request(
+ username,
+ password,
+ object : LoginCallback {
+ override fun success(loginResult: LoginResult) {
+ if (loginResult.pass) {
+ sessionManager.updateAccount(loginResult)
+ retryCallback()
+ } else {
+ callback.failure(LoginFailedException(loginResult.message))
+ }
+ }
+
+ override fun twoFactorPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ ) = callback.twoFactorPrompt()
+
+ override fun emailAuthPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ ) = callback.emailAuthPrompt()
+
+ // Should not happen here, but call the callback just in case.
+ override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password."))
+
+ override fun error(caught: Throwable) = callback.failure(caught)
+ },
+ )
+
+ private fun cancel() {
+ loginClient.cancel()
+ if (csrfTokenCall != null) {
+ csrfTokenCall!!.cancel()
+ csrfTokenCall = null
+ }
+ }
+
+ interface Callback {
+ fun success(token: String?)
+
+ fun failure(caught: Throwable?)
+
+ fun twoFactorPrompt()
+
+ fun emailAuthPrompt()
+ }
+
+ companion object {
+ private const val ANON_TOKEN = "+\\"
+ private const val MAX_RETRIES = 1
+ private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2
+ const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure."
+ const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token."
+ }
+}
+
+class InvalidLoginTokenException(
+ message: String,
+) : Exception(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
new file mode 100644
index 000000000..949f2dddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
@@ -0,0 +1,13 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Headers
+
+interface CsrfTokenInterface {
+ @Headers("Cache-Control: no-cache")
+ @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf")
+ fun getCsrfTokenCall(): Call
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
new file mode 100644
index 000000000..84481c918
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
@@ -0,0 +1,12 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
+import javax.inject.Inject
+
+class LogoutClient
+ @Inject
+ constructor(
+ private val store: CommonsCookieStorage,
+ ) {
+ fun logout() = store.clear()
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
new file mode 100644
index 000000000..8aa3d17a0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.auth.login
+
+interface LoginCallback {
+ fun success(loginResult: LoginResult)
+
+ fun twoFactorPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ )
+
+ fun emailAuthPrompt(
+ loginResult: LoginResult,
+ caught: Throwable,
+ token: String?,
+ )
+
+ fun passwordResetPrompt(token: String?)
+
+ fun error(caught: Throwable)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
new file mode 100644
index 000000000..a653b8b55
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
@@ -0,0 +1,276 @@
+package fr.free.nrw.commons.auth.login
+
+import android.text.TextUtils
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import timber.log.Timber
+import java.io.IOException
+
+/**
+ * Responsible for making login related requests to the server.
+ */
+class LoginClient(
+ private val loginInterface: LoginInterface,
+) {
+ private var tokenCall: Call? = null
+ private var loginCall: Call? = null
+
+ /**
+ * userLanguage
+ * It holds the value of the user's device language code.
+ * For example, if user's device language is English it will hold En
+ * The value will be fetched when the user clicks Login Button in the LoginActivity
+ */
+ private var userLanguage = ""
+
+ private fun getLoginToken() = loginInterface.getLoginToken()
+
+ fun request(
+ userName: String,
+ password: String,
+ cb: LoginCallback,
+ ) {
+ cancel()
+
+ tokenCall = getLoginToken()
+ tokenCall!!.enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ login(
+ userName,
+ password,
+ null,
+ null,
+ null,
+ response.body()!!.query()!!.loginToken(),
+ userLanguage,
+ cb,
+ )
+ }
+
+ override fun onFailure(
+ call: Call,
+ caught: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.error(caught)
+ }
+ },
+ )
+ }
+
+ fun login(
+ userName: String,
+ password: String,
+ retypedPassword: String?,
+ twoFactorCode: String?,
+ emailAuthCode: String?,
+ loginToken: String?,
+ userLanguage: String,
+ cb: LoginCallback,
+ ) {
+ this.userLanguage = userLanguage
+
+ loginCall =
+ if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ retypedPassword,
+ twoFactorCode,
+ emailAuthCode,
+ loginToken,
+ userLanguage,
+ true,
+ )
+ }
+
+ loginCall!!.enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ val loginResult = response.body()?.toLoginResult(password)
+ if (loginResult != null) {
+ if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
+ // The server could do some transformations on user names, e.g. on some
+ // wikis is uppercases the first letter.
+ getExtendedInfo(loginResult.userName, loginResult, cb)
+ } else if ("UI" == loginResult.status) {
+ when (loginResult) {
+ is OAuthResult ->
+ cb.twoFactorPrompt(
+ loginResult,
+ LoginFailedException(loginResult.message),
+ loginToken,
+ )
+
+ is EmailAuthResult ->
+ cb.emailAuthPrompt(
+ loginResult,
+ LoginFailedException(loginResult.message),
+ loginToken
+ )
+
+ is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
+
+ is LoginResult.Result ->
+ cb.error(
+ LoginFailedException(loginResult.message),
+ )
+ }
+ } else {
+ cb.error(LoginFailedException(loginResult.message))
+ }
+ } else {
+ cb.error(IOException("Login failed. Unexpected response."))
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ if (call.isCanceled) {
+ return
+ }
+ cb.error(t)
+ }
+ },
+ )
+ }
+
+ fun doLogin(
+ username: String,
+ password: String,
+ lastLoginResult: LoginResult?,
+ twoFactorCode: String,
+ userLanguage: String,
+ loginCallback: LoginCallback,
+ ) {
+ getLoginToken().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) = if (response.isSuccessful) {
+ val loginToken = response.body()?.query()?.loginToken()
+ loginToken?.let {
+ login(username, password, null,
+ if (lastLoginResult is OAuthResult) twoFactorCode else null,
+ if (lastLoginResult is EmailAuthResult) twoFactorCode else null,
+ it, userLanguage, loginCallback)
+ } ?: run {
+ loginCallback.error(IOException("Failed to retrieve login token"))
+ }
+ } else {
+ loginCallback.error(IOException("Failed to retrieve login token"))
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ loginCallback.error(t)
+ }
+ },
+ )
+ }
+
+ @Throws(Throwable::class)
+ fun loginBlocking(
+ userName: String,
+ password: String,
+ twoFactorCode: String? = null,
+ emailAuthCode: String? = null
+ ) {
+ val tokenResponse = getLoginToken().execute()
+ if (tokenResponse
+ .body()
+ ?.query()
+ ?.loginToken()
+ .isNullOrEmpty()
+ ) {
+ throw IOException("Unexpected response when getting login token.")
+ }
+
+ val loginToken = tokenResponse.body()?.query()?.loginToken()
+ val tempLoginCall =
+ if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ null,
+ twoFactorCode,
+ emailAuthCode,
+ loginToken,
+ userLanguage,
+ true,
+ )
+ }
+
+ val response = tempLoginCall.execute()
+ val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.")
+ val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.")
+
+ if ("UI" == loginResult.status) {
+ if (loginResult is OAuthResult || loginResult is EmailAuthResult) {
+ // TODO: Find a better way to boil up the warning about 2FA
+ throw LoginFailedException(loginResult.message)
+ }
+ throw LoginFailedException(loginResult.message)
+ }
+
+ if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
+ throw LoginFailedException(loginResult.message)
+ }
+ }
+
+ private fun getExtendedInfo(
+ userName: String,
+ loginResult: LoginResult,
+ cb: LoginCallback,
+ ) = loginInterface
+ .getUserInfo(userName)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe({ response: MwQueryResponse? ->
+ loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
+ loginResult.groups =
+ response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
+ cb.success(loginResult)
+ }, { caught: Throwable ->
+ Timber.e(caught, "Login succeeded but getting group information failed. ")
+ cb.error(caught)
+ })
+
+ fun cancel() {
+ tokenCall?.let {
+ it.cancel()
+ tokenCall = null
+ }
+
+ loginCall?.let {
+ it.cancel()
+ loginCall = null
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
new file mode 100644
index 000000000..fb5ad14c6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.auth.login
+
+class LoginFailedException(
+ message: String?,
+) : Throwable(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
new file mode 100644
index 000000000..39cbf7c9f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
@@ -0,0 +1,48 @@
+package fr.free.nrw.commons.auth.login
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Observable
+import retrofit2.Call
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+interface LoginInterface {
+ @Headers("Cache-Control: no-cache")
+ @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login")
+ fun getLoginToken(): Call
+
+ @Headers("Cache-Control: no-cache")
+ @FormUrlEncoded
+ @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+ fun postLogIn(
+ @Field("username") user: String?,
+ @Field("password") pass: String?,
+ @Field("logintoken") token: String?,
+ @Field("uselang") userLanguage: String?,
+ @Field("loginreturnurl") url: String?,
+ ): Call
+
+ @Headers("Cache-Control: no-cache")
+ @FormUrlEncoded
+ @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
+ fun postLogIn(
+ @Field("username") user: String?,
+ @Field("password") pass: String?,
+ @Field("retype") retypedPass: String?,
+ @Field("OATHToken") twoFactorCode: String?,
+ @Field("token") emailAuthToken: String?,
+ @Field("logintoken") loginToken: String?,
+ @Field("uselang") userLanguage: String?,
+ @Field("logincontinue") loginContinue: Boolean,
+ ): Call
+
+ @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
+ fun getUserInfo(
+ @Query("ususers") userName: String,
+ ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
new file mode 100644
index 000000000..0fb035eea
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
@@ -0,0 +1,64 @@
+package fr.free.nrw.commons.auth.login
+
+import com.google.gson.annotations.SerializedName
+import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult
+import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
+import fr.free.nrw.commons.auth.login.LoginResult.Result
+import fr.free.nrw.commons.wikidata.mwapi.MwServiceError
+
+class LoginResponse {
+ @SerializedName("error")
+ val error: MwServiceError? = null
+
+ @SerializedName("clientlogin")
+ private val clientLogin: ClientLogin? = null
+
+ fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
+}
+
+internal class ClientLogin {
+ private val status: String? = null
+ private val requests: List? = null
+ private val message: String? = null
+
+ @SerializedName("username")
+ private val userName: String? = null
+
+ fun toLoginResult(password: String): LoginResult {
+ var userMessage = message
+ if ("UI" == status) {
+ requests?.forEach { request ->
+ request.id()?.let {
+ if (it.endsWith("TOTPAuthenticationRequest")) {
+ return OAuthResult(status, userName, password, message)
+ } else if (it.endsWith("EmailAuthAuthenticationRequest")) {
+ return EmailAuthResult(status, userName, password, message)
+ } else if (it.endsWith("PasswordAuthenticationRequest")) {
+ return ResetPasswordResult(status, userName, password, message)
+ }
+ }
+ }
+ } else if ("PASS" != status && "FAIL" != status) {
+ // TODO: String resource -- Looks like needed for others in this class too
+ userMessage = "An unknown error occurred."
+ }
+ return Result(status ?: "", userName, password, userMessage)
+ }
+}
+
+internal class Request {
+ private val id: String? = null
+ private val required: String? = null
+ private val provider: String? = null
+ private val account: String? = null
+ internal val fields: Map? = null
+
+ fun id(): String? = id
+}
+
+internal class RequestField {
+ private val type: String? = null
+ private val label: String? = null
+ internal val help: String? = null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
new file mode 100644
index 000000000..99abaeeec
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.auth.login
+
+sealed class LoginResult(
+ val status: String,
+ val userName: String?,
+ val password: String?,
+ val message: String?,
+) {
+ var userId = 0
+ var groups = emptySet()
+ val pass: Boolean get() = "PASS" == status
+
+ class Result(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class OAuthResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class EmailAuthResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+
+ class ResetPasswordResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/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 72de0db70..000000000
--- a/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package fr.free.nrw.commons.caching;
-
-import com.github.varunpant.quadtree.Point;
-import com.github.varunpant.quadtree.QuadTree;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.upload.GpsCategoryModel;
-import timber.log.Timber;
-
-@Singleton
-public class CacheController {
-
- private final GpsCategoryModel gpsCategoryModel;
- private final QuadTree> quadTree;
- private double x, y;
- private double xMinus, xPlus, yMinus, yPlus;
-
- private static final int EARTH_RADIUS = 6378137;
-
- @Inject
- CacheController(GpsCategoryModel gpsCategoryModel) {
- this.gpsCategoryModel = gpsCategoryModel;
- quadTree = new QuadTree<>(-180, -90, +180, +90);
- }
-
- public void setQtPoint(double decLongitude, double decLatitude) {
- x = decLongitude;
- y = decLatitude;
- Timber.d("New QuadTree created");
- Timber.d("X (longitude) value: %f, Y (latitude) value: %f", x, y);
- }
-
- public void cacheCategory() {
- List pointCatList = new ArrayList<>();
- if (gpsCategoryModel.getGpsCatExists()) {
- pointCatList.addAll(gpsCategoryModel.getCategoryList());
- Timber.d("Categories being cached: %s", pointCatList);
- } else {
- Timber.d("No categories found, so no categories cached");
- }
- quadTree.set(x, y, pointCatList);
- }
-
- public List findCategory() {
- Point>[] pointsFound;
- //Convert decLatitude and decLongitude to a coordinate offset range
- convertCoordRange();
- pointsFound = quadTree.searchWithin(xMinus, yMinus, xPlus, yPlus);
- List displayCatList = new ArrayList<>();
- Timber.d("Points found in quadtree: %s", Arrays.toString(pointsFound));
-
- if (pointsFound.length != 0) {
- Timber.d("Entering for loop");
-
- for (Point> point : pointsFound) {
- Timber.d("Nearby point: %s", point);
- displayCatList = point.getValue();
- Timber.d("Nearby cat: %s", point.getValue());
- }
-
- Timber.d("Categories found in cache: %s", displayCatList);
- } else {
- Timber.d("No categories found in cache");
- }
- return displayCatList;
- }
-
- //Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters
- private void convertCoordRange() {
- //Position, decimal degrees
- double lat = y;
- double lon = x;
-
- //offsets in meters
- double offset = 100;
-
- //Coordinate offsets in radians
- double dLat = offset / EARTH_RADIUS;
- double dLon = offset / (EARTH_RADIUS * Math.cos(Math.PI * lat / 180));
-
- //OffsetPosition, decimal degrees
- yPlus = lat + dLat * 180 / Math.PI;
- yMinus = lat - dLat * 180 / Math.PI;
- xPlus = lon + dLon * 180 / Math.PI;
- xMinus = lon - dLon * 180 / Math.PI;
- Timber.d("Search within: xMinus=%s, yMinus=%s, xPlus=%s, yPlus=%s",
- xMinus, yMinus, xPlus, yPlus);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/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/CategoriesAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesAdapterFactory.java
deleted file mode 100644
index 417121c44..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesAdapterFactory.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import com.pedrogomez.renderers.ListAdapteeCollection;
-import com.pedrogomez.renderers.RVRendererAdapter;
-import com.pedrogomez.renderers.RendererBuilder;
-
-import java.util.Collections;
-import java.util.List;
-
-class CategoriesAdapterFactory {
- private final CategoriesRenderer.CategoryClickedListener listener;
-
- CategoriesAdapterFactory(CategoriesRenderer.CategoryClickedListener listener) {
- this.listener = listener;
- }
-
- public RVRendererAdapter create(List placeList) {
- RendererBuilder builder = new RendererBuilder()
- .bind(CategoryItem.class, new CategoriesRenderer(listener));
- ListAdapteeCollection collection = new ListAdapteeCollection<>(
- placeList != null ? placeList : Collections.emptyList());
- return new RVRendererAdapter<>(builder, collection);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
new file mode 100644
index 000000000..47147944c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
@@ -0,0 +1,332 @@
+package fr.free.nrw.commons.category
+
+import android.text.TextUtils
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.upload.GpsCategoryModel
+import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
+import fr.free.nrw.commons.utils.StringSortingUtils
+import io.reactivex.Observable
+import io.reactivex.functions.Function4
+import timber.log.Timber
+import java.util.Calendar
+import java.util.Date
+import javax.inject.Inject
+
+/**
+ * The model class for categories in upload
+ */
+class CategoriesModel
+ @Inject
+ constructor(
+ private val categoryClient: CategoryClient,
+ private val categoryDao: CategoryDao,
+ private val gpsCategoryModel: GpsCategoryModel,
+ ) {
+ private val selectedCategories: MutableList = mutableListOf()
+
+ /**
+ * Existing categories which are selected
+ */
+ private var selectedExistingCategories: MutableList = mutableListOf()
+
+ /**
+ * Returns true if an item is considered to be a spammy category which should be ignored
+ *
+ * @param item a category item that needs to be validated to know if it is spammy or not
+ * @return
+ */
+ fun isSpammyCategory(item: String): Boolean {
+
+ // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
+ val spammyCategory = item.matches("(.*)needing(.*)".toRegex())
+ || item.matches("(.*)taken on(.*)".toRegex())
+
+ // checks for
+ // dd/mm/yyyy or yy
+ // yyyy or yy/mm/dd
+ // yyyy or yy/mm
+ // mm/yyyy or yy
+ // for `yy` it is assumed that 20XX is implicit.
+ // with separators [., /, -]
+ val isIrrelevantCategory =
+ item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex())
+
+
+ if (spammyCategory) {
+ return true
+ }
+
+ if(isIrrelevantCategory){
+ return true
+ }
+
+ val hasYear = item.matches("(.*\\d{4}.*)".toRegex())
+ val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex())
+
+ // finally if there's 4 digits year exists in XXXX it should only be in 20XX range.
+ return hasYear && !validYearsRange
+ }
+
+ /**
+ * Updates category count in category dao
+ * @param item
+ */
+ fun updateCategoryCount(item: CategoryItem) {
+ var category = categoryDao.find(item.name)
+
+ // Newly used category...
+ if (category == null) {
+ category = Category(
+ null, item.name,
+ item.description,
+ item.thumbnail,
+ Date(),
+ 0
+ )
+ }
+ category.incTimesUsed()
+ categoryDao.save(category)
+ }
+
+ /**
+ * Regional category search
+ * @param term
+ * @param imageTitleList
+ * @return
+ */
+ fun searchAll(
+ term: String,
+ imageTitleList: List,
+ selectedDepictions: List,
+ ): Observable> =
+ suggestionsOrSearch(term, imageTitleList, selectedDepictions)
+ .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } }
+
+ private fun suggestionsOrSearch(
+ term: String,
+ imageTitleList: List,
+ selectedDepictions: List,
+ ): Observable> =
+ if (TextUtils.isEmpty(term)) {
+ Observable.combineLatest(
+ categoriesFromDepiction(selectedDepictions),
+ gpsCategoryModel.categoriesFromLocation,
+ titleCategories(imageTitleList),
+ Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)),
+ Function4(::combine),
+ )
+ } else {
+ categoryClient
+ .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
+ .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
+ .toObservable()
+ }
+
+ /**
+ * Fetches details of every category associated with selected depictions, converts them into
+ * CategoryItem and returns them in a list.
+ * If a selected depiction has no categories, the categories in which its P18 belongs are
+ * returned in the list.
+ *
+ * @param selectedDepictions selected DepictItems
+ * @return List of CategoryItem associated with selected depictions
+ */
+ private fun categoriesFromDepiction(selectedDepictions: List): Observable>? {
+ val observables = selectedDepictions.map { depictedItem ->
+ if (depictedItem.commonsCategories.isEmpty()) {
+ if (depictedItem.primaryImage == null) {
+ return@map Observable.just(emptyList())
+ }
+ Observable.just(
+ depictedItem.primaryImage
+ ).map { image ->
+ categoryClient
+ .getCategoriesOfImage(
+ image,
+ SEARCH_CATS_LIMIT,
+ ).map {
+ it.map { category ->
+ CategoryItem(
+ category.name,
+ category.description,
+ category.thumbnail,
+ category.isSelected,
+ )
+ }
+ }.blockingGet()
+ }.flatMapIterable { it }.toList()
+ .toObservable()
+ } else {
+ Observable
+ .fromIterable(
+ depictedItem.commonsCategories,
+ ).map { categoryItem ->
+ categoryClient
+ .getCategoriesByName(
+ categoryItem.name,
+ categoryItem.name,
+ SEARCH_CATS_LIMIT,
+ ).map {
+ CategoryItem(
+ it[0].name,
+ it[0].description,
+ it[0].thumbnail,
+ it[0].isSelected,
+ )
+ }.blockingGet()
+ }.toList()
+ .toObservable()
+ }
+ }
+ return Observable.concat(observables)
+ .scan(mutableListOf()) { accumulator, currentList ->
+ accumulator.apply { addAll(currentList) }
+ }
+ }
+
+ /**
+ * Fetches details of every category by their name, converts them into
+ * CategoryItem and returns them in a list.
+ *
+ * @param categoryNames selected Categories
+ * @return List of CategoryItem
+ */
+ fun getCategoriesByName(categoryNames: List): Observable>? =
+ Observable
+ .fromIterable(categoryNames)
+ .map { categoryName ->
+ buildCategories(categoryName)
+ }.filter { categoryItem ->
+ categoryItem.name != "Hidden"
+ }.toList()
+ .toObservable()
+
+ /**
+ * Fetches the categories and converts them into CategoryItem
+ */
+ fun buildCategories(categoryName: String): CategoryItem =
+ categoryClient
+ .getCategoriesByName(
+ categoryName,
+ categoryName,
+ SEARCH_CATS_LIMIT,
+ ).map {
+ if (it.isNotEmpty()) {
+ CategoryItem(
+ it[0].name,
+ it[0].description,
+ it[0].thumbnail,
+ it[0].isSelected,
+ )
+ } else {
+ CategoryItem(
+ "Hidden",
+ "Hidden",
+ "hidden",
+ false,
+ )
+ }
+ }.blockingGet()
+
+ private fun combine(
+ depictionCategories: List,
+ locationCategories: List,
+ titles: List,
+ recents: List,
+ ) = depictionCategories + locationCategories + titles + recents
+
+ /**
+ * Returns title based categories
+ * @param titleList
+ * @return
+ */
+ private fun titleCategories(titleList: List) =
+ if (titleList.isNotEmpty()) {
+ Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
+ searchResults.map { it as List }.flatten()
+ }
+ } else {
+ Observable.just(emptyList())
+ }
+
+ /**
+ * Return category for single title
+ * @param title
+ * @return
+ */
+ private fun getTitleCategories(title: String): Observable> =
+ categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable()
+
+ /**
+ * Handles category item selection
+ * @param item
+ */
+ fun onCategoryItemClicked(
+ item: CategoryItem,
+ media: Media?,
+ ) {
+ if (media == null) {
+ if (item.isSelected) {
+ selectedCategories.add(item)
+ updateCategoryCount(item)
+ } else {
+ selectedCategories.remove(item)
+ }
+ } else {
+ if (item.isSelected) {
+ if (media.categories?.contains(item.name) == true) {
+ selectedExistingCategories.add(item.name)
+ } else {
+ selectedCategories.add(item)
+ updateCategoryCount(item)
+ }
+ } else {
+ if (media.categories?.contains(item.name) == true) {
+ selectedExistingCategories.remove(item.name)
+ if (!media.categories?.contains(item.name)!!) {
+ val categoriesList: MutableList = ArrayList()
+ categoriesList.add(item.name)
+ categoriesList.addAll(media.categories!!)
+ media.categories = categoriesList
+ }
+ } else {
+ selectedCategories.remove(item)
+ }
+ }
+ }
+ }
+
+ /**
+ * Get Selected Categories
+ * @return
+ */
+ fun getSelectedCategories(): List = selectedCategories
+
+ /**
+ * Cleanup the existing in memory cache's
+ */
+ fun cleanUp() {
+ selectedCategories.clear()
+ selectedExistingCategories.clear()
+ }
+
+ companion object {
+ const val SEARCH_CATS_LIMIT = 25
+ }
+
+ /**
+ * Provides selected existing categories
+ *
+ * @return selected existing categories
+ */
+ fun getSelectedExistingCategories(): List = selectedExistingCategories
+
+ /**
+ * Initialize existing categories
+ *
+ * @param selectedExistingCategories existing categories
+ */
+ fun setSelectedExistingCategories(selectedExistingCategories: MutableList) {
+ this.selectedExistingCategories = selectedExistingCategories
+ }
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java
deleted file mode 100644
index 81cccdb72..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.CheckedTextView;
-
-import com.pedrogomez.renderers.Renderer;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
-
-class CategoriesRenderer extends Renderer {
- @BindView(R.id.tvName) CheckedTextView checkedView;
- private final CategoryClickedListener listener;
-
- CategoriesRenderer(CategoryClickedListener listener) {
- this.listener = listener;
- }
-
- @Override
- protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) {
- return layoutInflater.inflate(R.layout.layout_categories_item, viewGroup, false);
- }
-
- @Override
- protected void setUpView(View view) {
- ButterKnife.bind(this, view);
- }
-
- @Override
- protected void hookListeners(View view) {
- view.setOnClickListener(v -> {
- CategoryItem item = getContent();
- item.setSelected(!item.isSelected());
- checkedView.setChecked(item.isSelected());
- if (listener != null) {
- listener.categoryClicked(item);
- }
- });
- }
-
- @Override
- public void render() {
- CategoryItem item = getContent();
- checkedView.setChecked(item.isSelected());
- checkedView.setText(item.getName());
- }
-
- interface CategoryClickedListener {
- void categoryClicked(CategoryItem item);
- }
-}
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 93ddb60d5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java
+++ /dev/null
@@ -1,421 +0,0 @@
-package fr.free.nrw.commons.category;
-
-
-import android.content.SharedPreferences;
-import android.os.Bundle;
-import android.support.v7.app.AlertDialog;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.text.Editable;
-import android.text.TextUtils;
-import android.text.TextWatcher;
-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.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.jakewharton.rxbinding2.view.RxView;
-import com.jakewharton.rxbinding2.widget.RxTextView;
-import com.pedrogomez.renderers.RVRendererAdapter;
-
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import fr.free.nrw.commons.upload.GpsCategoryModel;
-import fr.free.nrw.commons.utils.StringSortingUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Observable;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.KeyEvent.ACTION_UP;
-import static android.view.KeyEvent.KEYCODE_BACK;
-
-/**
- * Displays the category suggestion and selection screen. Category search is initiated here.
- */
-public class CategorizationFragment extends CommonsDaggerSupportFragment {
-
- public static final int SEARCH_CATS_LIMIT = 25;
-
- @BindView(R.id.categoriesListBox)
- RecyclerView categoriesList;
- @BindView(R.id.categoriesSearchBox)
- EditText categoriesFilter;
- @BindView(R.id.categoriesSearchInProgress)
- ProgressBar categoriesSearchInProgress;
- @BindView(R.id.categoriesNotFound)
- TextView categoriesNotFoundView;
- @BindView(R.id.categoriesExplanation)
- TextView categoriesSkip;
-
- @Inject MediaWikiApi mwApi;
- @Inject @Named("default_preferences") SharedPreferences prefs;
- @Inject @Named("prefs") SharedPreferences prefsPrefs;
- @Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs;
- @Inject CategoryDao categoryDao;
- @Inject GpsCategoryModel gpsCategoryModel;
-
- private RVRendererAdapter categoriesAdapter;
- private OnCategoriesSaveHandler onCategoriesSaveHandler;
- private HashMap> categoriesCache;
- private List selectedCategories = new ArrayList<>();
- private TitleTextWatcher textWatcher = new TitleTextWatcher();
- private boolean hasDirectCategories = false;
-
- private final CategoriesAdapterFactory adapterFactory = new CategoriesAdapterFactory(item -> {
- if (item.isSelected()) {
- selectedCategories.add(item);
- updateCategoryCount(item);
- } else {
- selectedCategories.remove(item);
- }
- });
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container,
- Bundle savedInstanceState) {
- View rootView = inflater.inflate(R.layout.fragment_categorization, container, false);
- ButterKnife.bind(this, rootView);
-
- categoriesList.setLayoutManager(new LinearLayoutManager(getContext()));
-
- ArrayList items = new ArrayList<>();
- categoriesCache = new HashMap<>();
- if (savedInstanceState != null) {
- items.addAll(savedInstanceState.getParcelableArrayList("currentCategories"));
- //noinspection unchecked
- categoriesCache.putAll((HashMap>) savedInstanceState
- .getSerializable("categoriesCache"));
- }
-
- categoriesAdapter = adapterFactory.create(items);
- categoriesList.setAdapter(categoriesAdapter);
-
-
- categoriesFilter.addTextChangedListener(textWatcher);
-
- categoriesFilter.setOnFocusChangeListener((v, hasFocus) -> {
- if (!hasFocus) {
- ViewUtil.hideKeyboard(v);
- }
- });
-
- RxTextView.textChanges(categoriesFilter)
- .takeUntil(RxView.detaches(categoriesFilter))
- .debounce(500, TimeUnit.MILLISECONDS)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(filter -> updateCategoryList(filter.toString()));
- return rootView;
- }
-
- @Override
- public void onDestroyView() {
- categoriesFilter.removeTextChangedListener(textWatcher);
- super.onDestroyView();
- }
-
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
- menu.clear();
- inflater.inflate(R.menu.fragment_categorization, menu);
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- View rootView = getView();
- if (rootView != null) {
- rootView.setFocusableInTouchMode(true);
- rootView.requestFocus();
- rootView.setOnKeyListener((v, keyCode, event) -> {
- if (event.getAction() == ACTION_UP && keyCode == KEYCODE_BACK) {
- showBackButtonDialog();
- return true;
- }
- return false;
- });
- }
- }
-
- @Override
- public void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- int itemCount = categoriesAdapter.getItemCount();
- ArrayList items = new ArrayList<>(itemCount);
- for (int i = 0; i < itemCount; i++) {
- items.add(categoriesAdapter.getItem(i));
- }
- outState.putParcelableArrayList("currentCategories", items);
- outState.putSerializable("categoriesCache", categoriesCache);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem menuItem) {
- switch (menuItem.getItemId()) {
- case R.id.menu_save_categories:
- if (selectedCategories.size() > 0) {
- //Some categories selected, proceed to submission
- onCategoriesSaveHandler.onCategoriesSave(getStringList(selectedCategories));
- } else {
- //No categories selected, prompt the user to select some
- showConfirmationDialog();
- }
- return true;
- default:
- return super.onOptionsItemSelected(menuItem);
- }
- }
-
- @Override
- public void onActivityCreated(Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- setHasOptionsMenu(true);
- onCategoriesSaveHandler = (OnCategoriesSaveHandler) getActivity();
- getActivity().setTitle(R.string.categories_activity_title);
- }
-
- private void updateCategoryList(String filter) {
- Observable.fromIterable(selectedCategories)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .doOnSubscribe(disposable -> {
- categoriesSearchInProgress.setVisibility(View.VISIBLE);
- categoriesNotFoundView.setVisibility(View.GONE);
- categoriesSkip.setVisibility(View.GONE);
- categoriesAdapter.clear();
- })
- .observeOn(Schedulers.io())
- .concatWith(
- searchAll(filter)
- .mergeWith(searchCategories(filter))
- .concatWith(TextUtils.isEmpty(filter)
- ? defaultCategories() : Observable.empty())
- )
- .filter(categoryItem -> !containsYear(categoryItem.getName()))
- .distinct()
- .sorted(sortBySimilarity(filter))
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- s -> categoriesAdapter.add(s),
- Timber::e,
- () -> {
- categoriesAdapter.notifyDataSetChanged();
- categoriesSearchInProgress.setVisibility(View.GONE);
-
- if (categoriesAdapter.getItemCount() == selectedCategories.size()) {
- // There are no suggestions
- if (TextUtils.isEmpty(filter)) {
- // Allow to send image with no categories
- categoriesSkip.setVisibility(View.VISIBLE);
- } else {
- // Inform the user that the searched term matches no category
- categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter));
- categoriesNotFoundView.setVisibility(View.VISIBLE);
- }
- }
- }
- );
- }
-
- private Comparator sortBySimilarity(final String filter) {
- Comparator stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter);
- return (firstItem, secondItem) -> stringSimilarityComparator
- .compare(firstItem.getName(), secondItem.getName());
- }
-
- private List getStringList(List input) {
- List output = new ArrayList<>();
- for (CategoryItem item : input) {
- output.add(item.getName());
- }
- return output;
- }
-
- private Observable defaultCategories() {
- Observable directCat = directCategories();
- if (hasDirectCategories) {
- Timber.d("Image has direct Cat");
- return directCat
- .concatWith(gpsCategories())
- .concatWith(titleCategories())
- .concatWith(recentCategories());
- }
- else {
- Timber.d("Image has no direct Cat");
- return gpsCategories()
- .concatWith(titleCategories())
- .concatWith(recentCategories());
- }
- }
-
- private Observable directCategories() {
- String directCategory = directPrefs.getString("Category", "");
- // Strip newlines to prevent blank categories, and to tidy existing categories
- directCategory = directCategory.replace("\n", "");
-
- List categoryList = new ArrayList<>();
- Timber.d("Direct category found: " + "'" + directCategory + "'");
-
- if (!directCategory.equals("")) {
- hasDirectCategories = true;
- categoryList.add(directCategory);
- Timber.d("DirectCat does not equal emptyString. Direct Cat list has " + categoryList);
- }
- return Observable.fromIterable(categoryList).map(name -> new CategoryItem(name, false));
- }
-
- private Observable gpsCategories() {
- return Observable.fromIterable(gpsCategoryModel.getCategoryList())
- .map(name -> new CategoryItem(name, false));
- }
-
- private Observable titleCategories() {
- //Retrieve the title that was saved when user tapped submit icon
- String title = prefs.getString("Title", "");
-
- return mwApi
- .searchTitles(title, SEARCH_CATS_LIMIT)
- .map(name -> new CategoryItem(name, false));
- }
-
- private Observable recentCategories() {
- return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT))
- .map(s -> new CategoryItem(s, false));
- }
-
- private Observable searchAll(String term) {
- //If user hasn't typed anything in yet, get GPS and recent items
- if (TextUtils.isEmpty(term)) {
- return Observable.empty();
- }
-
- //if user types in something that is in cache, return cached category
- if (categoriesCache.containsKey(term)) {
- return Observable.fromIterable(categoriesCache.get(term))
- .map(name -> new CategoryItem(name, false));
- }
-
- //otherwise, search API for matching categories
- return mwApi
- .allCategories(term, SEARCH_CATS_LIMIT)
- .map(name -> new CategoryItem(name, false));
- }
-
- private Observable searchCategories(String term) {
- //If user hasn't typed anything in yet, get GPS and recent items
- if (TextUtils.isEmpty(term)) {
- return Observable.empty();
- }
-
- return mwApi
- .searchCategories(term, SEARCH_CATS_LIMIT)
- .map(s -> new CategoryItem(s, false));
- }
-
- private boolean containsYear(String item) {
- //Check for current and previous year to exclude these categories from removal
- Calendar now = Calendar.getInstance();
- int year = now.get(Calendar.YEAR);
- String yearInString = String.valueOf(year);
-
- int prevYear = year - 1;
- String prevYearInString = String.valueOf(prevYear);
- Timber.d("Previous year: %s", prevYearInString);
-
- //Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
- //And that item does not equal the current year or previous year
- //And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
- //Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
- return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString))
- || item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)")
- || (item.matches(".*0s.*") && !item.matches(".*(200|201)0s.*")));
- }
-
- private void updateCategoryCount(CategoryItem item) {
- Category category = categoryDao.find(item.getName());
-
- // Newly used category...
- if (category == null) {
- category = new Category(null, item.getName(), new Date(), 0);
- }
-
- category.incTimesUsed();
- categoryDao.save(category);
- }
-
- public int getCurrentSelectedCount() {
- return selectedCategories.size();
- }
-
- /**
- * Show dialog asking for confirmation to leave without saving categories.
- */
- public void showBackButtonDialog() {
- new AlertDialog.Builder(getActivity())
- .setMessage("Are you sure you want to go back? The image will not "
- + "have any categories saved.")
- .setTitle("Warning")
- .setPositiveButton("No", (dialog, id) -> {
- //No need to do anything, user remains on categorization screen
- })
- .setNegativeButton("Yes", (dialog, id) -> getActivity().finish())
- .create()
- .show();
- }
-
- private void showConfirmationDialog() {
- new AlertDialog.Builder(getActivity())
- .setMessage("Images without categories are rarely usable. "
- + "Are you sure you want to submit without selecting "
- + "categories?")
- .setTitle("No Categories Selected")
- .setPositiveButton("No, go back", (dialog, id) -> {
- //Exit menuItem so user can select their categories
- })
- .setNegativeButton("Yes, submit", (dialog, id) -> {
- //Proceed to submission
- onCategoriesSaveHandler.onCategoriesSave(getStringList(selectedCategories));
- })
- .create()
- .show();
- }
-
- private class TitleTextWatcher implements 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) {
- }
-
- @Override
- public void afterTextChanged(Editable editable) {
- if (getActivity() != null) {
- getActivity().invalidateOptionsMenu();
- }
- }
- }
-}
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 f2d83d2e5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/Category.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.net.Uri;
-
-import java.util.Date;
-
-/**
- * Represents a category
- */
-public class Category {
- private Uri contentUri;
- private String name;
- private Date lastUsed;
- private int timesUsed;
-
- public Category() {
- }
-
- public Category(Uri contentUri, String name, Date lastUsed, int timesUsed) {
- this.contentUri = contentUri;
- this.name = name;
- this.lastUsed = lastUsed;
- this.timesUsed = timesUsed;
- }
-
- /**
- * Gets name
- *
- * @return name
- */
- public String getName() {
- return name;
- }
-
- /**
- * Modifies name
- *
- * @param name Category name
- */
- public void setName(String name) {
- this.name = name;
- }
-
- /**
- * Gets last used date
- *
- * @return Last used date
- */
- public Date getLastUsed() {
- // warning: Date objects are mutable.
- return (Date)lastUsed.clone();
- }
-
- /**
- * Generates new last used date
- */
- private void touch() {
- lastUsed = new Date();
- }
-
- /**
- * Gets no. of times the category is used
- *
- * @return no. of times used
- */
- public int getTimesUsed() {
- return timesUsed;
- }
-
- /**
- * Increments timesUsed by 1 and sets last used date as now.
- */
- public void incTimesUsed() {
- timesUsed++;
- touch();
- }
-
- /**
- * Gets the content URI for this category
- *
- * @return content URI
- */
- public Uri getContentUri() {
- return contentUri;
- }
-
- /**
- * Modifies the content URI - marking this category as already saved in the database
- *
- * @param contentUri the content URI
- */
- public void setContentUri(Uri contentUri) {
- this.contentUri = contentUri;
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt
new file mode 100644
index 000000000..e4bfb957a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt
@@ -0,0 +1,17 @@
+package fr.free.nrw.commons.category
+
+import android.net.Uri
+import java.util.Date
+
+data class Category(
+ var contentUri: Uri? = null,
+ val name: String? = null,
+ val description: String? = null,
+ val thumbnail: String? = null,
+ val lastUsed: Date? = null,
+ var timesUsed: Int = 0
+) {
+ fun incTimesUsed() {
+ timesUsed++
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt
new file mode 100644
index 000000000..ef4ec3d39
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.category
+
+interface CategoryClickedListener {
+ fun categoryClicked(item: CategoryItem)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
new file mode 100644
index 000000000..b031f12f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
@@ -0,0 +1,157 @@
+package fr.free.nrw.commons.category
+
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Single
+import javax.inject.Inject
+import javax.inject.Singleton
+
+const val CATEGORY_PREFIX = "Category:"
+const val SUB_CATEGORY_CONTINUATION_PREFIX = "sub_category_"
+const val PARENT_CATEGORY_CONTINUATION_PREFIX = "parent_category_"
+const val CATEGORY_UNCATEGORISED = "uncategorised"
+const val CATEGORY_NEEDING_CATEGORIES = "needing categories"
+
+/**
+ * Category Client to handle custom calls to Commons MediaWiki APIs
+ */
+@Singleton
+class CategoryClient
+ @Inject
+ constructor(
+ private val categoryInterface: CategoryInterface,
+ ) : ContinuationClient() {
+ /**
+ * Searches for categories containing the specified string.
+ *
+ * @param filter The string to be searched
+ * @param itemLimit How many results are returned
+ * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
+ * @return
+ */
+ @JvmOverloads
+ fun searchCategories(
+ filter: String?,
+ itemLimit: Int,
+ offset: Int = 0,
+ ): Single> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset))
+
+ /**
+ * Searches for categories starting with the specified string.
+ *
+ * @param prefix The prefix to be searched
+ * @param itemLimit How many results are returned
+ * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
+ * @return
+ */
+ @JvmOverloads
+ fun searchCategoriesForPrefix(
+ prefix: String?,
+ itemLimit: Int,
+ offset: Int = 0,
+ ): Single> =
+ responseMapper(
+ categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset),
+ )
+
+ /**
+ * Fetches categories starting and ending with a specified name.
+ *
+ * @param startingCategoryName Name of the category to start
+ * @param endingCategoryName Name of the category to end
+ * @param itemLimit How many categories to return
+ * @param offset offset
+ * @return MwQueryResponse
+ */
+ @JvmOverloads
+ fun getCategoriesByName(
+ startingCategoryName: String?,
+ endingCategoryName: String?,
+ itemLimit: Int,
+ offset: Int = 0,
+ ): Single> =
+ responseMapper(
+ categoryInterface.getCategoriesByName(
+ startingCategoryName,
+ endingCategoryName,
+ itemLimit,
+ offset,
+ ),
+ )
+
+ /**
+ * Fetches categories belonging to an image (P18 of some wikidata entity).
+ *
+ * @param image P18 of some wikidata entity
+ * @param itemLimit How many categories to return
+ * @return Single Observable emitting the list of categories
+ */
+ fun getCategoriesOfImage(
+ image: String,
+ itemLimit: Int,
+ ): Single> =
+ responseMapper(
+ categoryInterface.getCategoriesByTitles(
+ "File:${image}",
+ itemLimit,
+ ),
+ )
+
+ /**
+ * The method takes categoryName as input and returns a List of Subcategories
+ * It uses the generator query API to get the subcategories in a category, 500 at a time.
+ *
+ * @param categoryName Category name as defined on commons
+ * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
+ */
+ fun getSubCategoryList(categoryName: String): Single> =
+ continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) {
+ categoryInterface.getSubCategoryList(
+ categoryName,
+ it,
+ )
+ }
+
+ /**
+ * The method takes categoryName as input and returns a List of parent categories
+ * It uses the generator query API to get the parent categories of a category, 500 at a time.
+ *
+ * @param categoryName Category name as defined on commons
+ * @return
+ */
+ fun getParentCategoryList(categoryName: String): Single> =
+ continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) {
+ categoryInterface.getParentCategoryList(categoryName, it)
+ }
+
+ fun resetSubCategoryContinuation(category: String) {
+ resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category)
+ }
+
+ fun resetParentCategoryContinuation(category: String) {
+ resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category)
+ }
+
+ override fun responseMapper(
+ networkResult: Single,
+ key: String?,
+ ): Single> =
+ networkResult
+ .map {
+ handleContinuationResponse(it.continuation(), key)
+ it.query()?.pages() ?: emptyList()
+ }.map {
+ it
+ .filter { page ->
+ // Null check is not redundant because some values could be null
+ // for mocks when running unit tests
+ page.categoryInfo()?.isHidden != true
+ }.map {
+ CategoryItem(
+ it.title().replace(CATEGORY_PREFIX, ""),
+ it.description().toString(),
+ it.thumbUrl().toString(),
+ false,
+ )
+ }
+ }
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java
deleted file mode 100644
index ed98d0449..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java
+++ /dev/null
@@ -1,168 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.ContentValues;
-import android.content.UriMatcher;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteQueryBuilder;
-import android.net.Uri;
-import android.support.annotation.NonNull;
-import android.text.TextUtils;
-
-import javax.inject.Inject;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.data.DBOpenHelper;
-import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
-import timber.log.Timber;
-
-import static android.content.UriMatcher.NO_MATCH;
-import static fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS;
-import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID;
-import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME;
-
-public class CategoryContentProvider extends CommonsDaggerContentProvider {
-
- // For URI matcher
- private static final int CATEGORIES = 1;
- private static final int CATEGORIES_ID = 2;
- private static final String BASE_PATH = "categories";
-
- public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH);
-
- private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
-
- static {
- uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES);
- uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID);
- }
-
- public static Uri uriForId(int id) {
- return Uri.parse(BASE_URI.toString() + "/" + id);
- }
-
- @Inject DBOpenHelper dbOpenHelper;
-
- @SuppressWarnings("ConstantConditions")
- @Override
- public Cursor query(@NonNull Uri uri, String[] projection, String selection,
- String[] selectionArgs, String sortOrder) {
- SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
- queryBuilder.setTables(TABLE_NAME);
-
- int uriType = uriMatcher.match(uri);
-
- SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
- Cursor cursor;
-
- switch (uriType) {
- case CATEGORIES:
- cursor = queryBuilder.query(db, projection, selection, selectionArgs,
- null, null, sortOrder);
- break;
- case CATEGORIES_ID:
- cursor = queryBuilder.query(db,
- ALL_FIELDS,
- "_id = ?",
- new String[]{uri.getLastPathSegment()},
- null,
- null,
- sortOrder
- );
- break;
- default:
- throw new IllegalArgumentException("Unknown URI" + uri);
- }
-
- cursor.setNotificationUri(getContext().getContentResolver(), uri);
-
- return cursor;
- }
-
- @Override
- public String getType(@NonNull Uri uri) {
- return null;
- }
-
- @SuppressWarnings("ConstantConditions")
- @Override
- public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
- int uriType = uriMatcher.match(uri);
- SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
- long id;
- switch (uriType) {
- case CATEGORIES:
- id = sqlDB.insert(TABLE_NAME, null, contentValues);
- break;
- default:
- throw new IllegalArgumentException("Unknown URI: " + uri);
- }
- getContext().getContentResolver().notifyChange(uri, null);
- return Uri.parse(BASE_URI + "/" + id);
- }
-
- @Override
- public int delete(@NonNull Uri uri, String s, String[] strings) {
- return 0;
- }
-
- @SuppressWarnings("ConstantConditions")
- @Override
- public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
- Timber.d("Hello, bulk insert! (CategoryContentProvider)");
- int uriType = uriMatcher.match(uri);
- SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
- sqlDB.beginTransaction();
- switch (uriType) {
- case CATEGORIES:
- for (ContentValues value : values) {
- Timber.d("Inserting! %s", value);
- sqlDB.insert(TABLE_NAME, null, value);
- }
- break;
- default:
- throw new IllegalArgumentException("Unknown URI: " + uri);
- }
- sqlDB.setTransactionSuccessful();
- sqlDB.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null);
- return values.length;
- }
-
- @SuppressWarnings("ConstantConditions")
- @Override
- public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
- String[] selectionArgs) {
- /*
- SQL Injection warnings: First, note that we're not exposing this to the
- outside world (exported="false"). Even then, we should make sure to sanitize
- all user input appropriately. Input that passes through ContentValues
- should be fine. So only issues are those that pass in via concating.
-
- In here, the only concat created argument is for id. It is cast to an int,
- and will error out otherwise.
- */
- int uriType = uriMatcher.match(uri);
- SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
- int rowsUpdated;
- switch (uriType) {
- case CATEGORIES_ID:
- if (TextUtils.isEmpty(selection)) {
- int id = Integer.valueOf(uri.getLastPathSegment());
- rowsUpdated = sqlDB.update(TABLE_NAME,
- contentValues,
- COLUMN_ID + " = ?",
- new String[]{String.valueOf(id)});
- } else {
- throw new IllegalArgumentException(
- "Parameter `selection` should be empty when updating an ID");
- }
- break;
- default:
- throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
- }
- getContext().getContentResolver().notifyChange(uri, null);
- return rowsUpdated;
- }
-}
-
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt
new file mode 100644
index 000000000..f5cec0fce
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt
@@ -0,0 +1,188 @@
+package fr.free.nrw.commons.category
+
+
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.content.UriMatcher.NO_MATCH
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteQueryBuilder
+import android.net.Uri
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.di.CommonsDaggerContentProvider
+import androidx.core.net.toUri
+
+class CategoryContentProvider : CommonsDaggerContentProvider() {
+
+ private val uriMatcher = UriMatcher(NO_MATCH).apply {
+ addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES)
+ addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID)
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ override fun query(uri: Uri, projection: Array?, selection: String?,
+ selectionArgs: Array?, sortOrder: String?): Cursor? {
+ val queryBuilder = SQLiteQueryBuilder().apply {
+ tables = TABLE_NAME
+ }
+
+ val uriType = uriMatcher.match(uri)
+ val db = requireDb()
+
+ val cursor: Cursor? = when (uriType) {
+ CATEGORIES -> queryBuilder.query(
+ db,
+ projection,
+ selection,
+ selectionArgs,
+ null,
+ null,
+ sortOrder
+ )
+ CATEGORIES_ID -> queryBuilder.query(
+ db,
+ ALL_FIELDS,
+ "_id = ?",
+ arrayOf(uri.lastPathSegment),
+ null,
+ null,
+ sortOrder
+ )
+ else -> throw IllegalArgumentException("Unknown URI $uri")
+ }
+
+ cursor?.setNotificationUri(requireContext().contentResolver, uri)
+ return cursor
+ }
+
+ override fun getType(uri: Uri): String? = null
+
+ @SuppressWarnings("ConstantConditions")
+ override fun insert(uri: Uri, contentValues: ContentValues?): Uri {
+ val uriType = uriMatcher.match(uri)
+ val id: Long
+ when (uriType) {
+ CATEGORIES -> {
+ id = requireDb().insert(TABLE_NAME, null, contentValues)
+ }
+ else -> throw IllegalArgumentException("Unknown URI: $uri")
+ }
+ requireContext().contentResolver?.notifyChange(uri, null)
+ return "${BASE_URI}/$id".toUri()
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ @SuppressWarnings("ConstantConditions")
+ override fun bulkInsert(uri: Uri, values: Array): Int {
+ val uriType = uriMatcher.match(uri)
+ val sqlDB = requireDb()
+ sqlDB.beginTransaction()
+ when (uriType) {
+ CATEGORIES -> {
+ for (value in values) {
+ sqlDB.insert(TABLE_NAME, null, value)
+ }
+ sqlDB.setTransactionSuccessful()
+ }
+ else -> throw IllegalArgumentException("Unknown URI: $uri")
+ }
+ sqlDB.endTransaction()
+ requireContext().contentResolver?.notifyChange(uri, null)
+ return values.size
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ override fun update(uri: Uri, contentValues: ContentValues?, selection: String?,
+ selectionArgs: Array?): Int {
+ val uriType = uriMatcher.match(uri)
+ val rowsUpdated: Int
+ when (uriType) {
+ CATEGORIES_ID -> {
+ if (TextUtils.isEmpty(selection)) {
+ val id = uri.lastPathSegment?.toInt()
+ ?: throw IllegalArgumentException("Invalid ID")
+ rowsUpdated = requireDb().update(
+ TABLE_NAME,
+ contentValues,
+ "$COLUMN_ID = ?",
+ arrayOf(id.toString())
+ )
+ } else {
+ throw IllegalArgumentException(
+ "Parameter `selection` should be empty when updating an ID")
+ }
+ }
+ else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType")
+ }
+ requireContext().contentResolver?.notifyChange(uri, null)
+ return rowsUpdated
+ }
+
+ companion object {
+ const val TABLE_NAME = "categories"
+
+ const val COLUMN_ID = "_id"
+ const val COLUMN_NAME = "name"
+ const val COLUMN_DESCRIPTION = "description"
+ const val COLUMN_THUMBNAIL = "thumbnail"
+ const val COLUMN_LAST_USED = "last_used"
+ const val COLUMN_TIMES_USED = "times_used"
+
+ // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+ val ALL_FIELDS = arrayOf(
+ COLUMN_ID,
+ COLUMN_NAME,
+ COLUMN_DESCRIPTION,
+ COLUMN_THUMBNAIL,
+ COLUMN_LAST_USED,
+ COLUMN_TIMES_USED
+ )
+
+ const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+ const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
+ "$COLUMN_ID INTEGER PRIMARY KEY," +
+ "$COLUMN_NAME TEXT," +
+ "$COLUMN_DESCRIPTION TEXT," +
+ "$COLUMN_THUMBNAIL TEXT," +
+ "$COLUMN_LAST_USED INTEGER," +
+ "$COLUMN_TIMES_USED INTEGER" +
+ ");"
+
+ fun uriForId(id: Int): Uri = Uri.parse("${BASE_URI}/$id")
+
+ fun onCreate(db: SQLiteDatabase) = db.execSQL(CREATE_TABLE_STATEMENT)
+
+ fun onDelete(db: SQLiteDatabase) {
+ db.execSQL(DROP_TABLE_STATEMENT)
+ onCreate(db)
+ }
+
+ fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+ if (from == to) return
+ if (from < 4) {
+ // doesn't exist yet
+ onUpdate(db, from + 1, to)
+ } else if (from == 4) {
+ // table added in version 5
+ onCreate(db)
+ onUpdate(db, from + 1, to)
+ } else if (from == 5) {
+ onUpdate(db, from + 1, to)
+ } else if (from == 17) {
+ db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;")
+ db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;")
+ onUpdate(db, from + 1, to)
+ }
+ }
+
+ // For URI matcher
+ private const val CATEGORIES = 1
+ private const val CATEGORIES_ID = 2
+ private const val BASE_PATH = "categories"
+ val BASE_URI: Uri = "content://${BuildConfig.CATEGORY_AUTHORITY}/${BASE_PATH}".toUri()
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java
deleted file mode 100644
index 010e97095..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java
+++ /dev/null
@@ -1,185 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.ContentProviderClient;
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.os.RemoteException;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Provider;
-
-public class CategoryDao {
-
- private final Provider clientProvider;
-
- @Inject
- public CategoryDao(@Named("category") Provider clientProvider) {
- this.clientProvider = clientProvider;
- }
-
- public void save(Category category) {
- ContentProviderClient db = clientProvider.get();
- try {
- if (category.getContentUri() == null) {
- category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
- } else {
- db.update(category.getContentUri(), toContentValues(category), null, null);
- }
- } catch (RemoteException e) {
- throw new RuntimeException(e);
- } finally {
- db.release();
- }
- }
-
- /**
- * Find persisted category in database, based on its name.
- *
- * @param name Category's name
- * @return category from database, or null if not found
- */
- @Nullable
- Category find(String name) {
- Cursor cursor = null;
- ContentProviderClient db = clientProvider.get();
- try {
- cursor = db.query(
- CategoryContentProvider.BASE_URI,
- Table.ALL_FIELDS,
- Table.COLUMN_NAME + "=?",
- new String[]{name},
- null);
- if (cursor != null && cursor.moveToFirst()) {
- return fromCursor(cursor);
- }
- } catch (RemoteException e) {
- // This feels lazy, but to hell with checked exceptions. :)
- throw new RuntimeException(e);
- } finally {
- if (cursor != null) {
- cursor.close();
- }
- db.release();
- }
- return null;
- }
-
- /**
- * Retrieve recently-used categories, ordered by descending date.
- *
- * @return a list containing recent categories
- */
- @NonNull
- List recentCategories(int limit) {
- List items = new ArrayList<>();
- Cursor cursor = null;
- ContentProviderClient db = clientProvider.get();
- try {
- cursor = db.query(
- CategoryContentProvider.BASE_URI,
- Table.ALL_FIELDS,
- null,
- new String[]{},
- Table.COLUMN_LAST_USED + " DESC");
- // fixme add a limit on the original query instead of falling out of the loop?
- while (cursor != null && cursor.moveToNext()
- && cursor.getPosition() < limit) {
- items.add(fromCursor(cursor).getName());
- }
- } catch (RemoteException e) {
- throw new RuntimeException(e);
- } finally {
- if (cursor != null) {
- cursor.close();
- }
- db.release();
- }
- return items;
- }
-
- @NonNull
- Category fromCursor(Cursor cursor) {
- // Hardcoding column positions!
- return new Category(
- CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))),
- cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
- new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))),
- cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED))
- );
- }
-
- private ContentValues toContentValues(Category category) {
- ContentValues cv = new ContentValues();
- cv.put(CategoryDao.Table.COLUMN_NAME, category.getName());
- cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime());
- cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed());
- return cv;
- }
-
- public static class Table {
- public static final String TABLE_NAME = "categories";
-
- public static final String COLUMN_ID = "_id";
- static final String COLUMN_NAME = "name";
- static final String COLUMN_LAST_USED = "last_used";
- static final String COLUMN_TIMES_USED = "times_used";
-
- // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
- public static final String[] ALL_FIELDS = {
- COLUMN_ID,
- COLUMN_NAME,
- COLUMN_LAST_USED,
- COLUMN_TIMES_USED
- };
-
- static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
-
- static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
- + COLUMN_ID + " INTEGER PRIMARY KEY,"
- + COLUMN_NAME + " STRING,"
- + COLUMN_LAST_USED + " INTEGER,"
- + COLUMN_TIMES_USED + " INTEGER"
- + ");";
-
- public static void onCreate(SQLiteDatabase db) {
- db.execSQL(CREATE_TABLE_STATEMENT);
- }
-
- public static void onDelete(SQLiteDatabase db) {
- db.execSQL(DROP_TABLE_STATEMENT);
- onCreate(db);
- }
-
- public static void onUpdate(SQLiteDatabase db, int from, int to) {
- if (from == to) {
- return;
- }
- if (from < 4) {
- // doesn't exist yet
- from++;
- onUpdate(db, from, to);
- return;
- }
- if (from == 4) {
- // table added in version 5
- onCreate(db);
- from++;
- onUpdate(db, from, to);
- return;
- }
- if (from == 5) {
- from++;
- onUpdate(db, from, to);
- return;
- }
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
new file mode 100644
index 000000000..3371da184
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
@@ -0,0 +1,194 @@
+package fr.free.nrw.commons.category
+
+import android.annotation.SuppressLint
+import android.content.ContentProviderClient
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.os.RemoteException
+
+import java.util.ArrayList
+import java.util.Date
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Provider
+
+class CategoryDao @Inject constructor(
+ @Named("category") private val clientProvider: Provider
+) {
+
+ fun save(category: Category) {
+ val db = clientProvider.get()
+ try {
+ if (category.contentUri == null) {
+ category.contentUri = db.insert(
+ CategoryContentProvider.BASE_URI,
+ toContentValues(category)
+ )
+ } else {
+ db.update(
+ category.contentUri!!,
+ toContentValues(category),
+ null,
+ null
+ )
+ }
+ } catch (e: RemoteException) {
+ throw RuntimeException(e)
+ } finally {
+ db.release()
+ }
+ }
+
+ /**
+ * Find persisted category in database, based on its name.
+ *
+ * @param name Category's name
+ * @return category from database, or null if not found
+ */
+ fun find(name: String): Category? {
+ var cursor: Cursor? = null
+ val db = clientProvider.get()
+ try {
+ cursor = db.query(
+ CategoryContentProvider.BASE_URI,
+ ALL_FIELDS,
+ "${COLUMN_NAME}=?",
+ arrayOf(name),
+ null
+ )
+ if (cursor != null && cursor.moveToFirst()) {
+ return fromCursor(cursor)
+ }
+ } catch (e: RemoteException) {
+ throw RuntimeException(e)
+ } finally {
+ cursor?.close()
+ db.release()
+ }
+ return null
+ }
+
+ /**
+ * Retrieve recently-used categories, ordered by descending date.
+ *
+ * @return a list containing recent categories
+ */
+ fun recentCategories(limit: Int): List {
+ val items = ArrayList()
+ var cursor: Cursor? = null
+ val db = clientProvider.get()
+ try {
+ cursor = db.query(
+ CategoryContentProvider.BASE_URI,
+ ALL_FIELDS,
+ null,
+ emptyArray(),
+ "$COLUMN_LAST_USED DESC"
+ )
+ while (cursor != null && cursor.moveToNext() && cursor.position < limit) {
+ val category = fromCursor(cursor)
+ if (category.name != null) {
+ items.add(
+ CategoryItem(
+ category.name,
+ category.description,
+ category.thumbnail,
+ false
+ )
+ )
+ }
+ }
+ } catch (e: RemoteException) {
+ throw RuntimeException(e)
+ } finally {
+ cursor?.close()
+ db.release()
+ }
+ return items
+ }
+
+ @SuppressLint("Range")
+ fun fromCursor(cursor: Cursor): Category {
+ // Hardcoding column positions!
+ return Category(
+ CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))),
+ cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
+ cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)),
+ cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)),
+ Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))),
+ cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED))
+ )
+ }
+
+ private fun toContentValues(category: Category): ContentValues {
+ return ContentValues().apply {
+ put(COLUMN_NAME, category.name)
+ put(COLUMN_DESCRIPTION, category.description)
+ put(COLUMN_THUMBNAIL, category.thumbnail)
+ put(COLUMN_LAST_USED, category.lastUsed?.time)
+ put(COLUMN_TIMES_USED, category.timesUsed)
+ }
+ }
+
+ companion object Table {
+ const val TABLE_NAME = "categories"
+
+ const val COLUMN_ID = "_id"
+ const val COLUMN_NAME = "name"
+ const val COLUMN_DESCRIPTION = "description"
+ const val COLUMN_THUMBNAIL = "thumbnail"
+ const val COLUMN_LAST_USED = "last_used"
+ const val COLUMN_TIMES_USED = "times_used"
+
+ // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
+ val ALL_FIELDS = arrayOf(
+ COLUMN_ID,
+ COLUMN_NAME,
+ COLUMN_DESCRIPTION,
+ COLUMN_THUMBNAIL,
+ COLUMN_LAST_USED,
+ COLUMN_TIMES_USED
+ )
+
+ const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
+
+ const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
+ "$COLUMN_ID INTEGER PRIMARY KEY," +
+ "$COLUMN_NAME STRING," +
+ "$COLUMN_DESCRIPTION STRING," +
+ "$COLUMN_THUMBNAIL STRING," +
+ "$COLUMN_LAST_USED INTEGER," +
+ "$COLUMN_TIMES_USED INTEGER" +
+ ");"
+
+ @SuppressLint("SQLiteString")
+ fun onCreate(db: SQLiteDatabase) {
+ db.execSQL(CREATE_TABLE_STATEMENT)
+ }
+
+ fun onDelete(db: SQLiteDatabase) {
+ db.execSQL(DROP_TABLE_STATEMENT)
+ onCreate(db)
+ }
+
+ @SuppressLint("SQLiteString")
+ fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
+ if (from == to) return
+ if (from < 4) {
+ // doesn't exist yet
+ onUpdate(db, from + 1, to)
+ } else if (from == 4) {
+ // table added in version 5
+ onCreate(db)
+ onUpdate(db, from + 1, to)
+ } else if (from == 5) {
+ onUpdate(db, from + 1, to)
+ } else if (from == 17) {
+ db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;")
+ db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;")
+ onUpdate(db, from + 1, to)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java
deleted file mode 100644
index fce2fac5e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java
+++ /dev/null
@@ -1,273 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.Context;
-import android.content.Intent;
-import android.database.DataSetObserver;
-import android.os.Bundle;
-import android.support.design.widget.TabLayout;
-import android.support.v4.app.Fragment;
-import android.support.v4.app.FragmentManager;
-import android.support.v4.view.ViewPager;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.AdapterView;
-import android.widget.FrameLayout;
-import android.widget.Toast;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.PageTitle;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.explore.ViewPagerAdapter;
-import fr.free.nrw.commons.media.MediaDetailPagerFragment;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-
-import static android.widget.Toast.LENGTH_SHORT;
-
-/**
- * This activity displays details of a particular category
- * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
- * a particular category on wikimedia commons.
- */
-
-public class CategoryDetailsActivity extends NavigationBaseActivity
- implements MediaDetailPagerFragment.MediaDetailProvider,
- AdapterView.OnItemClickListener{
-
-
- private FragmentManager supportFragmentManager;
- private CategoryImagesListFragment categoryImagesListFragment;
- private MediaDetailPagerFragment mediaDetails;
- private String categoryName;
- @BindView(R.id.mediaContainer) FrameLayout mediaContainer;
- @BindView(R.id.tabLayout) TabLayout tabLayout;
- @BindView(R.id.viewPager) ViewPager viewPager;
-
- ViewPagerAdapter viewPagerAdapter;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_category_details);
- ButterKnife.bind(this);
- supportFragmentManager = getSupportFragmentManager();
- viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
- viewPager.setAdapter(viewPagerAdapter);
- viewPager.setOffscreenPageLimit(2);
- tabLayout.setupWithViewPager(viewPager);
- setTabs();
- setPageTitle();
- initDrawer();
- forceInitBackButton();
- }
-
- /**
- * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
- * Set the fragments according to the tab selected in the viewPager.
- */
- private void setTabs() {
- List fragmentList = new ArrayList<>();
- List titleList = new ArrayList<>();
- categoryImagesListFragment = new CategoryImagesListFragment();
- SubCategoryListFragment subCategoryListFragment = new SubCategoryListFragment();
- SubCategoryListFragment parentCategoryListFragment = new SubCategoryListFragment();
- categoryName = getIntent().getStringExtra("categoryName");
- if (getIntent() != null && categoryName != null) {
- Bundle arguments = new Bundle();
- arguments.putString("categoryName", categoryName);
- arguments.putBoolean("isParentCategory", false);
- categoryImagesListFragment.setArguments(arguments);
- subCategoryListFragment.setArguments(arguments);
- Bundle parentCategoryArguments = new Bundle();
- parentCategoryArguments.putString("categoryName", categoryName);
- parentCategoryArguments.putBoolean("isParentCategory", true);
- parentCategoryListFragment.setArguments(parentCategoryArguments);
- }
- fragmentList.add(categoryImagesListFragment);
- titleList.add("MEDIA");
- fragmentList.add(subCategoryListFragment);
- titleList.add("SUBCATEGORIES");
- fragmentList.add(parentCategoryListFragment);
- titleList.add("PARENT CATEGORIES");
- viewPagerAdapter.setTabData(fragmentList, titleList);
- viewPagerAdapter.notifyDataSetChanged();
-
- }
-
- /**
- * Gets the passed categoryName from the intents and displays it as the page title
- */
- private void setPageTitle() {
- if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) {
- setTitle(getIntent().getStringExtra("categoryName"));
- }
- }
-
- /**
- * This method is called onClick of media inside category details (CategoryImageListFragment).
- */
- @Override
- public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
- tabLayout.setVisibility(View.GONE);
- viewPager.setVisibility(View.GONE);
- mediaContainer.setVisibility(View.VISIBLE);
- if (mediaDetails == null || !mediaDetails.isVisible()) {
- // set isFeaturedImage true for featured images, to include author field on media detail
- mediaDetails = new MediaDetailPagerFragment(false, true);
- FragmentManager supportFragmentManager = getSupportFragmentManager();
- supportFragmentManager
- .beginTransaction()
- .replace(R.id.mediaContainer, mediaDetails)
- .addToBackStack(null)
- .commit();
- supportFragmentManager.executePendingTransactions();
- }
- mediaDetails.showImage(i);
- forceInitBackButton();
- }
-
-
- /**
- * Consumers should be simply using this method to use this activity.
- * @param context A Context of the application package implementing this class.
- * @param categoryName Name of the category for displaying its details
- */
- public static void startYourself(Context context, String categoryName) {
- Intent intent = new Intent(context, CategoryDetailsActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
- intent.putExtra("categoryName", categoryName);
- context.startActivity(intent);
- }
-
- /**
- * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
- * @param i It is the index of which media object is to be returned which is same as
- * current index of viewPager.
- * @return Media Object
- */
- @Override
- public Media getMediaAtPosition(int i) {
- if (categoryImagesListFragment.getAdapter() == null) {
- // not yet ready to return data
- return null;
- } else {
- return (Media) categoryImagesListFragment.getAdapter().getItem(i);
- }
- }
-
- /**
- * This method is called on from getCount of MediaDetailPagerFragment
- * The viewpager will contain same number of media items as that of media elements in adapter.
- * @return Total Media count in the adapter
- */
- @Override
- public int getTotalMediaCount() {
- if (categoryImagesListFragment.getAdapter() == null) {
- return 0;
- }
- return categoryImagesListFragment.getAdapter().getCount();
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void notifyDatasetChanged() {
-
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void registerDataSetObserver(DataSetObserver observer) {
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void unregisterDataSetObserver(DataSetObserver observer) {
-
- }
-
- /**
- * This method inflates the menu in the toolbar
- */
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- MenuInflater inflater = getMenuInflater();
- inflater.inflate(R.menu.fragment_category_detail, menu);
- return super.onCreateOptionsMenu(menu);
- }
-
- /**
- * This method handles the logic on ItemSelect in toolbar menu
- * Currently only 1 choice is available to open category details page in browser
- */
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
-
- // Handle item selection
- switch (item.getItemId()) {
- case R.id.menu_browser_current_category:
- Intent viewIntent = new Intent();
- viewIntent.setAction(Intent.ACTION_VIEW);
- viewIntent.setData(new PageTitle(categoryName).getCanonicalUri());
- //check if web browser available
- if (viewIntent.resolveActivity(this.getPackageManager()) != null) {
- startActivity(viewIntent);
- } else {
- Toast toast = Toast.makeText(this, getString(R.string.no_web_browser), LENGTH_SHORT);
- toast.show();
- }
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-
- /**
- * This method is called on backPressed of anyFragment in the activity.
- * If condition is called when mediaDetailFragment is opened.
- */
- @Override
- public void onBackPressed() {
- if (supportFragmentManager.getBackStackEntryCount() == 1){
- // back to search so show search toolbar and hide navigation toolbar
- tabLayout.setVisibility(View.VISIBLE);
- viewPager.setVisibility(View.VISIBLE);
- mediaContainer.setVisibility(View.GONE);
- }
- super.onBackPressed();
- }
-
- /**
- * This method is called on success of API call for Images inside a category.
- * The viewpager will notified that number of items have changed.
- */
- public void viewPagerNotifyDataSetChanged() {
- if (mediaDetails!=null){
- mediaDetails.notifyDataSetChanged();
- }
- }
-
- /**
- * This method is called when viewPager has reached its end.
- * Fetches more images using search query and adds it to the grid view and viewpager adapter
- */
- public void requestMoreImages() {
- if (categoryImagesListFragment!=null){
- categoryImagesListFragment.fetchMoreImagesViewPager();
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
new file mode 100644
index 000000000..fefe462a9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
@@ -0,0 +1,265 @@
+package fr.free.nrw.commons.category
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import fr.free.nrw.commons.BuildConfig.COMMONS_URL
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.ViewPagerAdapter
+import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding
+import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment
+import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment
+import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment
+import fr.free.nrw.commons.media.MediaDetailPagerFragment
+import fr.free.nrw.commons.media.MediaDetailProvider
+import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
+import fr.free.nrw.commons.utils.handleWebUrl
+import fr.free.nrw.commons.wikidata.model.WikiSite
+import fr.free.nrw.commons.wikidata.model.page.PageTitle
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+/**
+ * This activity displays details of a particular category
+ * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
+ * a particular category on wikimedia commons.
+ */
+class CategoryDetailsActivity : BaseActivity(),
+ MediaDetailProvider,
+ CategoryImagesCallback {
+
+ private lateinit var supportFragmentManager: FragmentManager
+ private lateinit var categoriesMediaFragment: CategoriesMediaFragment
+ private var mediaDetails: MediaDetailPagerFragment? = null
+ private var categoryName: String? = null
+ private lateinit var viewPagerAdapter: ViewPagerAdapter
+
+ private lateinit var binding: ActivityCategoryDetailsBinding
+
+ @Inject
+ lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory
+
+ private val viewModel: CategoryDetailsViewModel by viewModels { categoryViewModelFactory }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityCategoryDetailsBinding.inflate(layoutInflater)
+ val view = binding.root
+ applyEdgeToEdgeAllInsets(view)
+ setContentView(view)
+ supportFragmentManager = getSupportFragmentManager()
+ viewPagerAdapter = ViewPagerAdapter(this, supportFragmentManager)
+ binding.viewPager.adapter = viewPagerAdapter
+ binding.viewPager.offscreenPageLimit = 2
+ binding.tabLayout.setupWithViewPager(binding.viewPager)
+ setSupportActionBar(binding.toolbarBinding.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ setTabs()
+ setPageTitle()
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED){
+ viewModel.bookmarkState.collect {
+ invalidateOptionsMenu()
+ }
+ }
+ }
+
+ }
+
+ /**
+ * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
+ * Set the fragments according to the tab selected in the viewPager.
+ */
+ private fun setTabs() {
+ categoriesMediaFragment = CategoriesMediaFragment()
+ val subCategoryListFragment = SubCategoriesFragment()
+ val parentCategoriesFragment = ParentCategoriesFragment()
+ categoryName = intent?.getStringExtra("categoryName")
+ if (intent != null && categoryName != null) {
+ val arguments = Bundle().apply {
+ putString("categoryName", categoryName)
+ }
+ categoriesMediaFragment.arguments = arguments
+ subCategoryListFragment.arguments = arguments
+ parentCategoriesFragment.arguments = arguments
+
+ viewModel.onCheckIfBookmarked(categoryName!!)
+ }
+
+ viewPagerAdapter.setTabs(
+ R.string.title_for_media to categoriesMediaFragment,
+ R.string.title_for_subcategories to subCategoryListFragment,
+ R.string.title_for_parent_categories to parentCategoriesFragment
+ )
+ viewPagerAdapter.notifyDataSetChanged()
+ }
+
+ /**
+ * Gets the passed categoryName from the intents and displays it as the page title
+ */
+ private fun setPageTitle() {
+ intent?.getStringExtra("categoryName")?.let {
+ title = it
+ }
+ }
+
+ /**
+ * This method is called onClick of media inside category details (CategoryImageListFragment).
+ */
+ override fun onMediaClicked(position: Int) {
+ binding.tabLayout.visibility = View.GONE
+ binding.viewPager.visibility = View.GONE
+ binding.mediaContainer.visibility = View.VISIBLE
+ if (mediaDetails == null || mediaDetails?.isVisible == false) {
+ // set isFeaturedImage true for featured images, to include author field on media detail
+ mediaDetails = MediaDetailPagerFragment.newInstance(false, true)
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.mediaContainer, mediaDetails!!)
+ .addToBackStack(null)
+ .commit()
+ supportFragmentManager.executePendingTransactions()
+ }
+ mediaDetails?.showImage(position)
+ }
+
+
+ companion object {
+ /**
+ * Consumers should be simply using this method to use this activity.
+ * @param context A Context of the application package implementing this class.
+ * @param categoryName Name of the category for displaying its details
+ */
+ fun startYourself(context: Context?, categoryName: String) {
+ val intent = Intent(context, CategoryDetailsActivity::class.java).apply {
+ putExtra("categoryName", categoryName)
+ }
+ context?.startActivity(intent)
+ }
+ }
+
+ /**
+ * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
+ * @param i It is the index of which media object is to be returned which is same as
+ * current index of viewPager.
+ * @return Media Object
+ */
+ override fun getMediaAtPosition(i: Int): Media? {
+ return categoriesMediaFragment.getMediaAtPosition(i)
+ }
+
+ /**
+ * This method is called on from getCount of MediaDetailPagerFragment
+ * The viewpager will contain same number of media items as that of media elements in adapter.
+ * @return Total Media count in the adapter
+ */
+ override fun getTotalMediaCount(): Int {
+ return categoriesMediaFragment.getTotalMediaCount()
+ }
+
+ override fun getContributionStateAt(position: Int): Int? {
+ return null
+ }
+
+ /**
+ * Reload media detail fragment once media is nominated
+ *
+ * @param index item position that has been nominated
+ */
+ override fun refreshNominatedMedia(index: Int) {
+ if (supportFragmentManager.backStackEntryCount == 1) {
+ onBackPressed()
+ onMediaClicked(index)
+ }
+ }
+
+ /**
+ * This method inflates the menu in the toolbar
+ */
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.fragment_category_detail, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ /**
+ * This method handles the logic on ItemSelect in toolbar menu
+ * Currently only 1 choice is available to open category details page in browser
+ */
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.menu_browser_current_category -> {
+ val title = PageTitle(CATEGORY_PREFIX + categoryName, WikiSite(COMMONS_URL))
+
+ handleWebUrl(this, Uri.parse(title.canonicalUri))
+ true
+ }
+
+ R.id.menu_bookmark_current_category -> {
+ categoryName?.let {
+ viewModel.onBookmarkClick(categoryName = it)
+ }
+ true
+ }
+
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
+ menu?.run {
+ val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category)
+ if (bookmarkMenuItem != null) {
+ val icon = if(viewModel.bookmarkState.value){
+ R.drawable.menu_ic_round_star_filled_24px
+ } else {
+ R.drawable.menu_ic_round_star_border_24px
+ }
+
+ bookmarkMenuItem.setIcon(icon)
+ }
+ }
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ /**
+ * This method is called on backPressed of anyFragment in the activity.
+ * If condition is called when mediaDetailFragment is opened.
+ */
+ @Deprecated("This method has been deprecated in favor of using the" +
+ "{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." +
+ "The OnBackPressedDispatcher controls how back button events are dispatched" +
+ "to one or more {@link OnBackPressedCallback} objects.")
+ override fun onBackPressed() {
+ if (supportFragmentManager.backStackEntryCount == 1) {
+ binding.tabLayout.visibility = View.VISIBLE
+ binding.viewPager.visibility = View.VISIBLE
+ binding.mediaContainer.visibility = View.GONE
+ }
+ super.onBackPressed()
+ }
+
+ /**
+ * This method is called on success of API call for Images inside a category.
+ * The viewpager will notified that number of items have changed.
+ */
+ override fun viewPagerNotifyDataSetChanged() {
+ mediaDetails?.notifyDataSetChanged()
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt
new file mode 100644
index 000000000..a50f25669
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt
@@ -0,0 +1,109 @@
+package fr.free.nrw.commons.category
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
+import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * ViewModal for [CategoryDetailsActivity]
+ */
+class CategoryDetailsViewModel(
+ private val bookmarkCategoriesDao: BookmarkCategoriesDao
+) : ViewModel() {
+
+ private val _bookmarkState = MutableStateFlow(false)
+ val bookmarkState = _bookmarkState.asStateFlow()
+
+
+ /**
+ * Used to check if bookmark exists for the given category in DB
+ * based on that bookmark state is updated
+ * @param categoryName
+ */
+ fun onCheckIfBookmarked(categoryName: String) {
+ viewModelScope.launch {
+ val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName)
+ _bookmarkState.update {
+ isBookmarked
+ }
+ }
+ }
+
+ /**
+ * Handles event when bookmark button is clicked from view
+ * based on that category is bookmarked or removed in/from in the DB
+ * and bookmark state is update as well
+ * @param categoryName
+ */
+ fun onBookmarkClick(categoryName: String) {
+ if (_bookmarkState.value) {
+ deleteBookmark(categoryName)
+ _bookmarkState.update {
+ false
+ }
+ } else {
+ addBookmark(categoryName)
+ _bookmarkState.update {
+ true
+ }
+ }
+ }
+
+
+ /**
+ * Add bookmark into DB
+ *
+ * @param categoryName
+ */
+ private fun addBookmark(categoryName: String) {
+ viewModelScope.launch {
+ val categoryItem = BookmarksCategoryModal(
+ categoryName = categoryName
+ )
+
+ bookmarkCategoriesDao.insert(categoryItem)
+ }
+ }
+
+
+ /**
+ * Delete bookmark from DB
+ *
+ * @param categoryName
+ */
+ private fun deleteBookmark(categoryName: String) {
+ viewModelScope.launch {
+ bookmarkCategoriesDao.delete(
+ BookmarksCategoryModal(
+ categoryName = categoryName
+ )
+ )
+ }
+ }
+
+ /**
+ * View model factory to create [CategoryDetailsViewModel]
+ *
+ * @property bookmarkCategoriesDao
+ * @constructor Create empty View model factory
+ */
+ class ViewModelFactory @Inject constructor(
+ private val bookmarkCategoriesDao: BookmarkCategoriesDao
+ ) : ViewModelProvider.Factory {
+
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) {
+ CategoryDetailsViewModel(bookmarkCategoriesDao) as T
+ } else {
+ throw IllegalArgumentException("Unknown class name")
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt
new file mode 100644
index 000000000..22cb19172
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt
@@ -0,0 +1,144 @@
+package fr.free.nrw.commons.category
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.actions.PageEditClient
+import fr.free.nrw.commons.notification.NotificationHelper
+import fr.free.nrw.commons.utils.ViewUtilWrapper
+import io.reactivex.Observable
+import io.reactivex.Single
+import javax.inject.Inject
+import javax.inject.Named
+import timber.log.Timber
+
+
+class CategoryEditHelper @Inject constructor(
+ private val notificationHelper: NotificationHelper,
+ @Named("commons-page-edit") val pageEditClient: PageEditClient,
+ private val viewUtil: ViewUtilWrapper,
+ @Named("username") private val username: String
+) {
+
+ /**
+ * Public interface to edit categories
+ * @param context
+ * @param media
+ * @param categories
+ * @return
+ */
+ fun makeCategoryEdit(
+ context: Context,
+ media: Media,
+ categories: List,
+ wikiText: String
+ ): Single {
+ viewUtil.showShortToast(
+ context,
+ context.getString(R.string.category_edit_helper_make_edit_toast)
+ )
+ return addCategory(media, categories, wikiText)
+ .flatMapSingle { result ->
+ Single.just(showCategoryEditNotification(context, media, result))
+ }
+ .firstOrError()
+ }
+
+ /**
+ * Rebuilds the WikiText with new categories and post it on server
+ *
+ * @param media
+ * @param categories to be added
+ * @return
+ */
+ private fun addCategory(
+ media: Media,
+ categories: List?,
+ wikiText: String
+ ): Observable {
+ Timber.d("thread is category adding %s", Thread.currentThread().name)
+ val summary = "Adding categories"
+ val buffer = StringBuilder()
+
+ // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
+ val wikiTextWithoutCategory: String = when {
+ wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized"))
+ wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category"))
+ else -> ""
+ }
+
+ if (!categories.isNullOrEmpty()) {
+ // If the categories list is empty, when reading the categories of a picture,
+ // the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
+ // So that after selecting some category, "None selected" should be removed from list
+ for (category in categories) {
+ if (category != "None selected" || !wikiText.contains("Uncategorized")) {
+ buffer.append("[[Category:").append(category).append("]]\n")
+ }
+ }
+ categories.dropWhile {
+ it == "None selected"
+ }
+ } else {
+ buffer.append("{{subst:unc}}")
+ }
+
+ val appendText = wikiTextWithoutCategory + buffer
+ return pageEditClient.edit(media.filename!!, "$appendText\n", summary)
+ }
+
+ private fun showCategoryEditNotification(
+ context: Context,
+ media: Media,
+ result: Boolean
+ ): Boolean {
+ val title: String
+ val message: String
+
+ if (result) {
+ title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
+ context.getString(R.string.category_edit_helper_show_edit_title_success)
+
+ val categoriesInMessage = StringBuilder()
+ val mediaCategoryList = media.categories
+ for ((index, category) in mediaCategoryList?.withIndex()!!) {
+ categoriesInMessage.append(category)
+ if (index != mediaCategoryList.size - 1) {
+ categoriesInMessage.append(",")
+ }
+ }
+
+ message = context.resources.getQuantityString(
+ R.plurals.category_edit_helper_show_edit_message_if,
+ mediaCategoryList.size,
+ categoriesInMessage.toString()
+ )
+ } else {
+ title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
+ context.getString(R.string.category_edit_helper_show_edit_title)
+ message = context.getString(R.string.category_edit_helper_edit_message_else)
+ }
+
+ val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
+ notificationHelper.showNotification(
+ context,
+ title,
+ message,
+ NOTIFICATION_EDIT_CATEGORY,
+ browserIntent
+ )
+ return result
+ }
+
+ interface Callback {
+ fun updateCategoryDisplay(categories: List?): Boolean
+ }
+
+ companion object {
+ const val NOTIFICATION_EDIT_CATEGORY = 1
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java
deleted file mode 100644
index 3495d710c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageController.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import java.util.List;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-
-@Singleton
-public class CategoryImageController {
-
- private MediaWikiApi mediaWikiApi;
-
- @Inject
- public CategoryImageController(MediaWikiApi mediaWikiApi) {
- this.mediaWikiApi = mediaWikiApi;
- }
-
- /**
- * Takes a category name as input and calls the API to get a list of images for that category
- * @param categoryName
- * @return
- */
- public List getCategoryImages(String categoryName) {
- return mediaWikiApi.getCategoryImages(categoryName);
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java
deleted file mode 100644
index 941201235..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java
+++ /dev/null
@@ -1,244 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import org.jsoup.Jsoup;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
-
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-
-import javax.annotation.Nullable;
-
-import fr.free.nrw.commons.Media;
-import timber.log.Timber;
-
-public class CategoryImageUtils {
-
- /**
- * The method iterates over the child nodes to return a list of Media objects
- * @param childNodes
- * @return
- */
- public static List getMediaList(NodeList childNodes) {
- List categoryImages = new ArrayList<>();
- for (int i = 0; i < childNodes.getLength(); i++) {
- Node node = childNodes.item(i);
- if (getMediaFromPage(node).getFilename().substring(0,5).equals("File:")){
- categoryImages.add(getMediaFromPage(node));
- }
- }
-
- return categoryImages;
- }
-
- /**
- * The method iterates over the child nodes to return a list of Subcategory name
- * sorted alphabetically
- * @param childNodes
- * @return
- */
- public static List getSubCategoryList(NodeList childNodes) {
- List subCategories = new ArrayList<>();
- for (int i = 0; i < childNodes.getLength(); i++) {
- Node node = childNodes.item(i);
- subCategories.add(getMediaFromPage(node).getFilename());
- }
- Collections.sort(subCategories);
- return subCategories;
- }
-
- /**
- * Creates a new Media object from the XML response as received by the API
- * @param node
- * @return
- */
- private static Media getMediaFromPage(Node node) {
- Media media = new Media(null,
- getImageUrl(node),
- getFileName(node),
- getDescription(node),
- getDataLength(node),
- getDateCreated(node),
- getDateCreated(node),
- getCreator(node)
- );
-
- media.setLicense(getLicense(node));
-
- return media;
- }
-
- /**
- * Extracts the filename of the uploaded image
- * @param document
- * @return
- */
- private static String getFileName(Node document) {
- Element element = (Element) document;
- return element.getAttribute("title");
- }
-
- /**
- * Extracts the image description for that particular upload
- * @param document
- * @return
- */
- private static String getDescription(Node document) {
- return getMetaDataValue(document, "ImageDescription");
- }
-
- /**
- * Extracts license information from the image meta data
- * @param document
- * @return
- */
- private static String getLicense(Node document) {
- return getMetaDataValue(document, "License");
- }
-
- /**
- * Returns the parsed value of artist from the response
- * The artist information is returned as a HTML string from the API. Jsoup library parses the HTML string
- * to extract just the text value
- * @param document
- * @return
- */
- private static String getCreator(Node document) {
- String artist = getMetaDataValue(document, "Artist");
- if (artist != null) {
- return Jsoup.parse(artist).text();
- }
- return null;
- }
-
- /**
- * Returns the parsed date of creation of the image
- * @param document
- * @return
- */
- private static Date getDateCreated(Node document) {
- String dateTime = getMetaDataValue(document, "DateTime");
- if (dateTime != null && !dateTime.equals("")) {
- SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
- try {
- return format.parse(dateTime);
- } catch (ParseException e) {
- Timber.d("Error occurred while parsing date %s", dateTime);
- return new Date();
- }
- }
- return new Date();
- }
-
- /**
- * @param document
- * @return Returns the url attribute from the imageInfo node
- */
- private static String getImageUrl(Node document) {
- Element element = (Element) getImageInfo(document);
- if (element != null) {
- return element.getAttribute("url");
- }
- return null;
- }
-
- /**
- * Takes the node document and gives out the attribute length from the node document
- * @param document
- * @return
- */
- private static long getDataLength(Node document) {
- Element element = (Element) document;
- if (element != null) {
- String length = element.getAttribute("length");
- if (length != null && !length.equals("")) {
- return Long.parseLong(length);
- }
- }
- return 0L;
- }
-
- /**
- * Generic method to get the value of any meta as returned by the getMetaData function
- * @param document node document as returned by API
- * @param metaName the name of meta node to be returned
- * @return
- */
- private static String getMetaDataValue(Node document, String metaName) {
- Element metaData = getMetaData(document, metaName);
- if (metaData != null) {
- return metaData.getAttribute("value");
- }
- return null;
- }
-
- /**
- * Generic method to return an element taking the node document and metaName as input
- * @param document node document as returned by API
- * @param metaName the name of meta node to be returned
- * @return
- */
- @Nullable
- private static Element getMetaData(Node document, String metaName) {
- Node extraMetaData = getExtraMetaData(document);
- if (extraMetaData != null) {
- Node node = getNode(extraMetaData, metaName);
- if (node != null) {
- return (Element) node;
- }
- }
- return null;
- }
-
- /**
- * Extracts extmetadata from the response XML
- * @param document
- * @return
- */
- @Nullable
- private static Node getExtraMetaData(Node document) {
- Node imageInfo = getImageInfo(document);
- if (imageInfo != null) {
- return getNode(imageInfo, "extmetadata");
- }
- return null;
- }
-
- /**
- * Extracts the ii node from the imageinfo node
- * @param document
- * @return
- */
- @Nullable
- private static Node getImageInfo(Node document) {
- Node imageInfo = getNode(document, "imageinfo");
- if (imageInfo != null) {
- return getNode(imageInfo, "ii");
- }
- return null;
- }
-
- /**
- * Takes a parent node as input and returns a child node if present
- * @param node parent node
- * @param nodeName child node name
- * @return
- */
- @Nullable
- public static Node getNode(Node node, String nodeName) {
- NodeList childNodes = node.getChildNodes();
- for (int i = 0; i < childNodes.getLength(); i++) {
- Node nodeItem = childNodes.item(i);
- Element item = (Element) nodeItem;
- if (item.getTagName().equals(nodeName)) {
- return nodeItem;
- }
- }
- return null;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
deleted file mode 100644
index eafc46e68..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java
+++ /dev/null
@@ -1,259 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.content.Context;
-import android.content.Intent;
-import android.database.DataSetObserver;
-import android.os.Bundle;
-import android.support.v4.app.FragmentManager;
-import android.support.v4.app.FragmentTransaction;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.AdapterView;
-
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.auth.AuthenticatedActivity;
-import fr.free.nrw.commons.explore.SearchActivity;
-import fr.free.nrw.commons.media.MediaDetailPagerFragment;
-import fr.free.nrw.commons.theme.NavigationBaseActivity;
-
-/**
- * This activity displays pictures of a particular category
- * Its generic and simply takes the name of category name in its start intent to load all images in
- * a particular category. This activity is currently being used to display a list of featured images,
- * which is nothing but another category on wikimedia commons.
- */
-
-public class CategoryImagesActivity
- extends AuthenticatedActivity
- implements FragmentManager.OnBackStackChangedListener,
- MediaDetailPagerFragment.MediaDetailProvider,
- AdapterView.OnItemClickListener{
-
-
- private FragmentManager supportFragmentManager;
- private CategoryImagesListFragment categoryImagesListFragment;
- private MediaDetailPagerFragment mediaDetails;
-
- @Override
- protected void onAuthCookieAcquired(String authCookie) {
-
- }
-
- @Override
- protected void onAuthFailure() {
-
- }
-
- /**
- * This method is called on backPressed of anyFragment in the activity.
- * We are changing the icon here from back to hamburger icon.
- */
- @Override
- public void onBackPressed() {
- initDrawer();
- super.onBackPressed();
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_category_images);
- ButterKnife.bind(this);
-
- // Activity can call methods in the fragment by acquiring a
- // reference to the Fragment from FragmentManager, using findFragmentById()
- supportFragmentManager = getSupportFragmentManager();
- setCategoryImagesFragment();
- supportFragmentManager.addOnBackStackChangedListener(this);
- requestAuthToken();
- initDrawer();
- setPageTitle();
- }
-
- /**
- * Gets the categoryName from the intent and initializes the fragment for showing images of that category
- */
- private void setCategoryImagesFragment() {
- categoryImagesListFragment = new CategoryImagesListFragment();
- String categoryName = getIntent().getStringExtra("categoryName");
- if (getIntent() != null && categoryName != null) {
- Bundle arguments = new Bundle();
- arguments.putString("categoryName", categoryName);
- categoryImagesListFragment.setArguments(arguments);
- FragmentTransaction transaction = supportFragmentManager.beginTransaction();
- transaction
- .add(R.id.fragmentContainer, categoryImagesListFragment)
- .commit();
- }
- }
-
- /**
- * Gets the passed title from the intents and displays it as the page title
- */
- private void setPageTitle() {
- if (getIntent() != null && getIntent().getStringExtra("title") != null) {
- setTitle(getIntent().getStringExtra("title"));
- }
- }
-
- @Override
- public void onBackStackChanged() {
- }
-
- /**
- * This method is called onClick of media inside category details (CategoryImageListFragment).
- */
- @Override
- public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
- if (mediaDetails == null || !mediaDetails.isVisible()) {
- // set isFeaturedImage true for featured images, to include author field on media detail
- mediaDetails = new MediaDetailPagerFragment(false, true);
- FragmentManager supportFragmentManager = getSupportFragmentManager();
- supportFragmentManager
- .beginTransaction()
- .hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount()))
- .add(R.id.fragmentContainer, mediaDetails)
- .addToBackStack(null)
- .commit();
- // Reason for using hide, add instead of replace is to maintain scroll position after
- // coming back to the search activity. See https://github.com/commons-app/apps-android-commons/issues/1631
- // https://stackoverflow.com/questions/11353075/how-can-i-maintain-fragment-state-when-added-to-the-back-stack/19022550#19022550 supportFragmentManager.executePendingTransactions();
- }
- mediaDetails.showImage(i);
- forceInitBackButton();
- }
-
- /**
- * This method is called on backPressed when mediaDetailFragment is opened in the activity.
- */
- @Override
- protected void onResume() {
- if (supportFragmentManager.getBackStackEntryCount()==1){
- //FIXME: Temporary fix for screen rotation inside media details. If we don't call onBackPressed then fragment stack is increasing every time.
- //FIXME: Similar issue like this https://github.com/commons-app/apps-android-commons/issues/894
- onBackPressed();
- }
- super.onResume();
- }
-
- /**
- * Consumers should be simply using this method to use this activity.
- * @param context A Context of the application package implementing this class.
- * @param title Page title
- * @param categoryName Name of the category for displaying its images
- */
- public static void startYourself(Context context, String title, String categoryName) {
- Intent intent = new Intent(context, CategoryImagesActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
- intent.putExtra("title", title);
- intent.putExtra("categoryName", categoryName);
- context.startActivity(intent);
- }
-
- /**
- * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
- * @param i It is the index of which media object is to be returned which is same as
- * current index of viewPager.
- * @return Media Object
- */
- @Override
- public Media getMediaAtPosition(int i) {
- if (categoryImagesListFragment.getAdapter() == null) {
- // not yet ready to return data
- return null;
- } else {
- return (Media) categoryImagesListFragment.getAdapter().getItem(i);
- }
- }
-
- /**
- * This method is called on success of API call for featured Images.
- * The viewpager will notified that number of items have changed.
- */
- public void viewPagerNotifyDataSetChanged() {
- if (mediaDetails!=null){
- mediaDetails.notifyDataSetChanged();
- }
- }
-
- /**
- * This method is called on from getCount of MediaDetailPagerFragment
- * The viewpager will contain same number of media items as that of media elements in adapter.
- * @return Total Media count in the adapter
- */
- @Override
- public int getTotalMediaCount() {
- if (categoryImagesListFragment.getAdapter() == null) {
- return 0;
- }
- return categoryImagesListFragment.getAdapter().getCount();
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void notifyDatasetChanged() {
-
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void registerDataSetObserver(DataSetObserver observer) {
-
- }
-
- /**
- * This method is never called but it was in MediaDetailProvider Interface
- * so it needs to be overrided.
- */
- @Override
- public void unregisterDataSetObserver(DataSetObserver observer) {
-
- }
-
- /**
- * This method inflates the menu in the toolbar
- */
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- MenuInflater inflater = getMenuInflater();
- inflater.inflate(R.menu.menu_search, menu);
- return super.onCreateOptionsMenu(menu);
- }
-
- /**
- * This method handles the logic on ItemSelect in toolbar menu
- * Currently only 1 choice is available to open search page of the app
- */
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
-
- // Handle item selection
- switch (item.getItemId()) {
- case R.id.action_search:
- NavigationBaseActivity.startActivityWithFlags(this, SearchActivity.class);
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-
- /**
- * This method is called when viewPager has reached its end.
- * Fetches more images using search query and adds it to the gridView and viewpager adapter
- */
- public void requestMoreImages() {
- if (categoryImagesListFragment!=null){
- categoryImagesListFragment.fetchMoreImagesViewPager();
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
new file mode 100644
index 000000000..9fe811f74
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
@@ -0,0 +1,7 @@
+package fr.free.nrw.commons.category
+
+interface CategoryImagesCallback {
+ fun viewPagerNotifyDataSetChanged()
+
+ fun onMediaClicked(position: Int)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
deleted file mode 100644
index a78157ee2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java
+++ /dev/null
@@ -1,270 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.annotation.SuppressLint;
-import android.content.SharedPreferences;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AbsListView;
-import android.widget.AdapterView;
-import android.widget.GridView;
-import android.widget.ListAdapter;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import dagger.android.support.DaggerFragment;
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.utils.NetworkUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Observable;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
-
-/**
- * Displays images for a particular category with load more on scrolling incorporated
- */
-public class CategoryImagesListFragment extends DaggerFragment {
-
- private static int TIMEOUT_SECONDS = 15;
-
- private GridViewAdapter gridAdapter;
-
- @BindView(R.id.statusMessage)
- TextView statusTextView;
- @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
- @BindView(R.id.categoryImagesList) GridView gridView;
- @BindView(R.id.parentLayout) RelativeLayout parentLayout;
- private boolean hasMoreImages = true;
- private boolean isLoading = true;
- private String categoryName = null;
-
- @Inject CategoryImageController controller;
- @Inject @Named("category_prefs") SharedPreferences categoryPreferences;
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View v = inflater.inflate(R.layout.fragment_category_images, container, false);
- ButterKnife.bind(this, v);
- return v;
- }
-
- @Override
- public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity());
- initViews();
- }
-
- /**
- * Initializes the UI elements for the fragment
- * Setup the grid view to and scroll listener for it
- */
- private void initViews() {
- String categoryName = getArguments().getString("categoryName");
- if (getArguments() != null && categoryName != null) {
- this.categoryName = categoryName;
- resetQueryContinueValues(categoryName);
- initList();
- setScrollListener();
- }
- }
-
- /**
- * Query continue values determine the last page that was loaded for the particular keyword
- * This method resets those values, so that the results can be queried from the first page itself
- * @param keyword
- */
- private void resetQueryContinueValues(String keyword) {
- SharedPreferences.Editor editor = categoryPreferences.edit();
- editor.remove(keyword);
- editor.apply();
- }
-
- /**
- * Checks for internet connection and then initializes the grid view with first 10 images of that category
- */
- @SuppressLint("CheckResult")
- private void initList() {
- if(!NetworkUtils.isInternetConnectionEstablished(getContext())) {
- handleNoInternet();
- return;
- }
-
- isLoading = true;
- progressBar.setVisibility(VISIBLE);
- Observable.fromCallable(() -> controller.getCategoryImages(categoryName))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .subscribe(this::handleSuccess, this::handleError);
- }
-
- /**
- * Handles the UI updates for no internet scenario
- */
- private void handleNoInternet() {
- progressBar.setVisibility(GONE);
- if (gridAdapter == null || gridAdapter.isEmpty()) {
- statusTextView.setVisibility(VISIBLE);
- statusTextView.setText(getString(R.string.no_internet));
- } else {
- ViewUtil.showSnackbar(parentLayout, R.string.no_internet);
- }
- }
-
- /**
- * Logs and handles API error scenario
- * @param throwable
- */
- private void handleError(Throwable throwable) {
- Timber.e(throwable, "Error occurred while loading images inside a category");
- try{
- ViewUtil.showSnackbar(parentLayout, R.string.error_loading_images);
- initErrorView();
- }catch (Exception e){
- e.printStackTrace();
- }
-
- }
-
- /**
- * Handles the UI updates for a error scenario
- */
- private void initErrorView() {
- progressBar.setVisibility(GONE);
- if (gridAdapter == null || gridAdapter.isEmpty()) {
- statusTextView.setVisibility(VISIBLE);
- statusTextView.setText(getString(R.string.no_images_found));
- } else {
- statusTextView.setVisibility(GONE);
- }
- }
-
- /**
- * Initializes the adapter with a list of Media objects
- * @param mediaList List of new Media to be displayed
- */
- private void setAdapter(List mediaList) {
- gridAdapter = new GridViewAdapter(this.getContext(), R.layout.layout_category_images, mediaList);
- gridView.setAdapter(gridAdapter);
- }
-
- /**
- * Sets the scroll listener for the grid view so that more images are fetched when the user scrolls down
- * Checks if the category has more images before loading
- * Also checks whether images are currently being fetched before triggering another request
- */
- private void setScrollListener() {
- gridView.setOnScrollListener(new AbsListView.OnScrollListener() {
- @Override
- public void onScrollStateChanged(AbsListView view, int scrollState) {
- }
-
- @Override
- public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
- if (hasMoreImages && !isLoading && (firstVisibleItem + visibleItemCount + 1 >= totalItemCount)) {
- isLoading = true;
- fetchMoreImages();
- }
- if (!hasMoreImages){
- progressBar.setVisibility(GONE);
- }
- }
- });
- }
-
- /**
- * This method is called when viewPager has reached its end.
- * Fetches more images for the category and adds it to the grid view and viewpager adapter
- */
- public void fetchMoreImagesViewPager(){
- if (hasMoreImages && !isLoading) {
- isLoading = true;
- fetchMoreImages();
- }
- if (!hasMoreImages){
- progressBar.setVisibility(GONE);
- }
- }
-
- /**
- * Fetches more images for the category and adds it to the grid view adapter
- */
- @SuppressLint("CheckResult")
- private void fetchMoreImages() {
- if(!NetworkUtils.isInternetConnectionEstablished(getContext())) {
- handleNoInternet();
- return;
- }
-
- progressBar.setVisibility(VISIBLE);
- Observable.fromCallable(() -> controller.getCategoryImages(categoryName))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .subscribe(this::handleSuccess, this::handleError);
- }
-
- /**
- * Handles the success scenario
- * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter
- * @param collection List of new Media to be displayed
- */
- private void handleSuccess(List collection) {
- if(collection == null || collection.isEmpty()) {
- initErrorView();
- hasMoreImages = false;
- return;
- }
-
- if(gridAdapter == null) {
- setAdapter(collection);
- } else {
- if (gridAdapter.containsAll(collection)) {
- hasMoreImages = false;
- return;
- }
- gridAdapter.addItems(collection);
- try {
- ((CategoryImagesActivity) getContext()).viewPagerNotifyDataSetChanged();
- }catch (Exception e){
- e.printStackTrace();
- }
- try {
- ((CategoryDetailsActivity) getContext()).viewPagerNotifyDataSetChanged();
- }catch (Exception e){
- e.printStackTrace();
- }
- }
- progressBar.setVisibility(GONE);
- isLoading = false;
- statusTextView.setVisibility(GONE);
- }
-
- /**
- * It return an instance of gridView adapter which helps in extracting media details
- * used by the gridView
- * @return GridView Adapter
- */
- public ListAdapter getAdapter() {
- return gridView.getAdapter();
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
new file mode 100644
index 000000000..3888ef889
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
@@ -0,0 +1,90 @@
+package fr.free.nrw.commons.category
+
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import io.reactivex.Single
+import retrofit2.http.GET
+import retrofit2.http.Query
+import retrofit2.http.QueryMap
+
+/**
+ * Interface for interacting with Commons category related APIs
+ */
+interface CategoryInterface {
+ /**
+ * Searches for categories with the specified name.
+ *
+ * @param filter The string to be searched
+ * @param itemLimit How many results are returned
+ * @return
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14",
+ )
+ fun searchCategories(
+ @Query("gsrsearch") filter: String?,
+ @Query("gsrlimit") itemLimit: Int,
+ @Query("gsroffset") offset: Int,
+ ): Single
+
+ /**
+ * Searches for categories starting with the specified prefix.
+ *
+ * @param prefix The string to be searched
+ * @param itemLimit How many results are returned
+ * @return
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
+ )
+ fun searchCategoriesForPrefix(
+ @Query("gacprefix") prefix: String?,
+ @Query("gaclimit") itemLimit: Int,
+ @Query("gacoffset") offset: Int,
+ ): Single
+
+ /**
+ * Fetches categories starting and ending with a specified name.
+ *
+ * @param startingCategory Name of the category to start
+ * @param endingCategory Name of the category to end
+ * @param itemLimit How many categories to return
+ * @param offset offset
+ * @return MwQueryResponse
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
+ )
+ fun getCategoriesByName(
+ @Query("gacfrom") startingCategory: String?,
+ @Query("gacto") endingCategory: String?,
+ @Query("gaclimit") itemLimit: Int,
+ @Query("gacoffset") offset: Int,
+ ): Single
+
+ /**
+ * Fetches non-hidden categories by titles.
+ *
+ * @param titles titles to fetch categories for (e.g. File:)
+ * @param itemLimit How many categories to return
+ * @return MwQueryResponse
+ */
+ @GET(
+ "w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden",
+ )
+ fun getCategoriesByTitles(
+ @Query("titles") titles: String?,
+ @Query("gcllimit") itemLimit: Int,
+ ): Single
+
+ @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
+ fun getSubCategoryList(
+ @Query("gcmtitle") categoryName: String,
+ @QueryMap(encoded = true) continuation: Map,
+ ): Single
+
+ @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50")
+ fun getParentCategoryList(
+ @Query("titles") categoryName: String?,
+ @QueryMap(encoded = true) continuation: Map,
+ ): Single
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java
deleted file mode 100644
index f6bacfb51..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.os.Parcel;
-import android.os.Parcelable;
-
-class CategoryItem implements Parcelable {
- private final String name;
- private 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];
- }
- };
-
- CategoryItem(String name, boolean selected) {
- this.name = name;
- this.selected = selected;
- }
-
- private CategoryItem(Parcel in) {
- name = in.readString();
- selected = in.readInt() == 1;
- }
-
- public String getName() {
- return name;
- }
-
- public boolean isSelected() {
- return selected;
- }
-
- public void setSelected(boolean selected) {
- this.selected = selected;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel parcel, int flags) {
- parcel.writeString(name);
- parcel.writeInt(selected ? 1 : 0);
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
-
- CategoryItem that = (CategoryItem) o;
-
- return name.equals(that.name);
-
- }
-
- @Override
- public int hashCode() {
- return name.hashCode();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt
new file mode 100644
index 000000000..d0ee8d53c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt
@@ -0,0 +1,27 @@
+package fr.free.nrw.commons.category
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class CategoryItem(
+ val name: String,
+ val description: String?,
+ val thumbnail: String?,
+ var isSelected: Boolean,
+) : Parcelable {
+ override fun toString(): String = "CategoryItem: '$name'"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as CategoryItem
+
+ if (name != other.name) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int = name.hashCode()
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt
new file mode 100644
index 000000000..0322cd7b6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons.category
+
+import io.reactivex.Single
+
+abstract class ContinuationClient {
+ private val continuationStore: MutableMap?> = mutableMapOf()
+ private val continuationExists: MutableMap = mutableMapOf()
+
+ private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true
+
+ fun continuationRequest(
+ prefix: String,
+ name: String,
+ requestFunction: (Map) -> Single,
+ ): Single> {
+ val key = "$prefix$name"
+ return if (hasMorePagesFor(key)) {
+ responseMapper(requestFunction(continuationStore[key] ?: emptyMap()), key)
+ } else {
+ Single.just(emptyList())
+ }
+ }
+
+ abstract fun responseMapper(
+ networkResult: Single,
+ key: String? = null,
+ ): Single>
+
+ fun handleContinuationResponse(
+ continuation: Map?,
+ key: String?,
+ ) {
+ if (key != null) {
+ continuationExists[key] =
+ continuation?.let { continuation ->
+ continuationStore[key] = continuation
+ true
+ } ?: false
+ }
+ }
+
+ protected fun resetContinuation(
+ prefix: String,
+ category: String,
+ ) {
+ continuationExists.remove("$prefix$category")
+ continuationStore.remove("$prefix$category")
+ }
+
+ /**
+ * Remove the existing the key from continuationExists and continuationStore
+ *
+ * @param prefix
+ * @param userName the username
+ */
+ protected fun resetUserContinuation(
+ prefix: String,
+ userName: String,
+ ) {
+ continuationExists.remove("$prefix$userName")
+ continuationStore.remove("$prefix$userName")
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java
deleted file mode 100644
index f8c54905e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import android.app.Activity;
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.TextView;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import fr.free.nrw.commons.Media;
-import fr.free.nrw.commons.MediaWikiImageView;
-import fr.free.nrw.commons.R;
-
-/**
- * This is created to only display UI implementation. Needs to be changed in real implementation
- */
-
-public class GridViewAdapter extends ArrayAdapter {
- private Context context;
- private List data;
-
- public GridViewAdapter(Context context, int layoutResourceId, List data) {
- super(context, layoutResourceId, data);
- this.context = context;
- this.data = data;
- }
-
- /**
- * Adds more item to the list
- * Its triggered on scrolling down in the list
- * @param images
- */
- public void addItems(List images) {
- if (data == null) {
- data = new ArrayList<>();
- }
- data.addAll(images);
- notifyDataSetChanged();
- }
-
- /**
- * Check the first item in the new list with old list and returns true if they are same
- * Its triggered on successful response of the fetch images API.
- * @param images
- */
- public boolean containsAll(List images){
- if (data == null) {
- data = new ArrayList<>();
- }
- return images.get(0).getFilename().equals(data.get(0).getFilename());
- }
-
- @Override
- public boolean isEmpty() {
- return data == null || data.isEmpty();
- }
-
- /**
- * Sets up the UI for the category image item
- * @param position
- * @param convertView
- * @param parent
- * @return
- */
- @Override
- public View getView(int position, View convertView, ViewGroup parent) {
-
- if (convertView == null) {
- LayoutInflater inflater = ((Activity) context).getLayoutInflater();
- convertView = inflater.inflate(R.layout.layout_category_images, null);
- }
-
- Media item = data.get(position);
- MediaWikiImageView imageView = convertView.findViewById(R.id.categoryImageView);
- TextView fileName = convertView.findViewById(R.id.categoryImageTitle);
- TextView author = convertView.findViewById(R.id.categoryImageAuthor);
- fileName.setText(item.getDisplayTitle());
- setAuthorView(item, author);
- imageView.setMedia(item);
- return convertView;
- }
-
- /**
- * Shows author information if its present
- * @param item
- * @param author
- */
- private void setAuthorView(Media item, TextView author) {
- if (item.getCreator() != null && !item.getCreator().equals("")) {
- String uploadedByTemplate = context.getString(R.string.image_uploaded_by);
- author.setText(String.format(uploadedByTemplate, item.getCreator()));
- } else {
- author.setVisibility(View.GONE);
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
new file mode 100644
index 000000000..0198c61a5
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
@@ -0,0 +1,106 @@
+package fr.free.nrw.commons.category
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.TextView
+import com.facebook.drawee.view.SimpleDraweeView
+import fr.free.nrw.commons.Media
+import fr.free.nrw.commons.R
+
+
+/**
+ * This is created to only display UI implementation. Needs to be changed in real implementation
+ */
+class GridViewAdapter(
+ context: Context,
+ layoutResourceId: Int,
+ private var data: MutableList?
+) : ArrayAdapter(context, layoutResourceId, data ?: mutableListOf()) {
+
+ /**
+ * Adds more items to the list
+ * It's triggered on scrolling down in the list
+ * @param images
+ */
+ fun addItems(images: List) {
+ if (data == null) {
+ data = mutableListOf()
+ }
+ data?.addAll(images)
+ notifyDataSetChanged()
+ }
+
+ /**
+ * Checks the first item in the new list with the old list and returns true if they are the same
+ * It's triggered on a successful response of the fetch images API.
+ * @param images
+ */
+ fun containsAll(images: List?): Boolean {
+ if (images.isNullOrEmpty()) {
+ return false
+ }
+ if (data.isNullOrEmpty()) {
+ data = mutableListOf()
+ return false
+ }
+ val fileName = data?.get(0)?.filename
+ val imageName = images[0].filename
+ return imageName == fileName
+ }
+
+ override fun isEmpty(): Boolean {
+ return data.isNullOrEmpty()
+ }
+
+ /**
+ * Sets up the UI for the category image item
+ * @param position
+ * @param convertView
+ * @param parent
+ * @return
+ */
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: LayoutInflater.from(context).inflate(
+ R.layout.layout_category_images,
+ parent,
+ false
+ )
+
+ val item = data?.get(position)
+ val imageView = view.findViewById(R.id.categoryImageView)
+ val fileName = view.findViewById(R.id.categoryImageTitle)
+ val uploader = view.findViewById(R.id.categoryImageAuthor)
+
+ item?.let {
+ fileName.text = it.mostRelevantCaption
+ setUploaderView(it, uploader)
+ imageView.setImageURI(it.thumbUrl)
+ }
+
+ return view
+ }
+
+ /**
+ * @return the Media item at the given position
+ */
+ override fun getItem(position: Int): Media? {
+ return data?.get(position)
+ }
+
+ /**
+ * Shows author information if it's present
+ * @param item
+ * @param uploader
+ */
+ @SuppressLint("StringFormatInvalid")
+ private fun setUploaderView(item: Media, uploader: TextView) {
+ uploader.text = context.getString(
+ R.string.image_uploaded_by,
+ item.getAuthorOrUser()
+ )
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java
deleted file mode 100644
index 5899d5905..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package fr.free.nrw.commons.category;
-
-import java.util.List;
-
-public interface OnCategoriesSaveHandler {
- void onCategoriesSave(List categories);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt
new file mode 100644
index 000000000..68200992c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.category
+
+interface OnCategoriesSaveHandler {
+ fun onCategoriesSave(categories: List)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/QueryContinue.java b/app/src/main/java/fr/free/nrw/commons/category/QueryContinue.java
deleted file mode 100644
index e12d5a778..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/QueryContinue.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package fr.free.nrw.commons.category;
-
-/**
- * For APIs that return paginated responses, MediaWiki APIs uses the QueryContinue to facilitate fetching of subsequent pages
- * https://www.mediawiki.org/wiki/API:Raw_query_continue
- */
-public class QueryContinue {
- private String continueParam;
- private String gcmContinueParam;
-
- public QueryContinue(String continueParam, String gcmContinueParam) {
- this.continueParam = continueParam;
- this.gcmContinueParam = gcmContinueParam;
- }
-
- public String getGcmContinueParam() {
- return gcmContinueParam;
- }
-
- public String getContinueParam() {
- return continueParam;
- }
-}
-
diff --git a/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java
deleted file mode 100644
index d3000e9b6..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java
+++ /dev/null
@@ -1,167 +0,0 @@
-package fr.free.nrw.commons.category;
-
-
-import android.content.Intent;
-import android.content.res.Configuration;
-import android.os.Bundle;
-import android.support.v7.widget.GridLayoutManager;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import com.pedrogomez.renderers.RVRendererAdapter;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
-import fr.free.nrw.commons.explore.categories.SearchCategoriesAdapterFactory;
-import fr.free.nrw.commons.mwapi.MediaWikiApi;
-import fr.free.nrw.commons.utils.NetworkUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.Observable;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.schedulers.Schedulers;
-import timber.log.Timber;
-
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
-
-/**
- * Displays the category search screen.
- */
-
-public class SubCategoryListFragment extends CommonsDaggerSupportFragment {
-
- private static int TIMEOUT_SECONDS = 15;
-
- @BindView(R.id.imagesListBox)
- RecyclerView categoriesRecyclerView;
- @BindView(R.id.imageSearchInProgress)
- ProgressBar progressBar;
- @BindView(R.id.imagesNotFound)
- TextView categoriesNotFoundView;
-
- private String categoryName = null;
- @Inject MediaWikiApi mwApi;
-
- private RVRendererAdapter categoriesAdapter;
- private boolean isParentCategory = true;
-
- private final SearchCategoriesAdapterFactory adapterFactory = new SearchCategoriesAdapterFactory(item -> {
- // Open SubCategory Details page
- Intent intent = new Intent(getContext(), CategoryDetailsActivity.class);
- intent.putExtra("categoryName", item);
- getContext().startActivity(intent);
-
- });
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) {
- View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false);
- ButterKnife.bind(this, rootView);
- categoryName = getArguments().getString("categoryName");
- isParentCategory = getArguments().getBoolean("isParentCategory");
- initSubCategoryList();
- if(getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){
- categoriesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
- }
- else{
- categoriesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2));
- }
- ArrayList items = new ArrayList<>();
- categoriesAdapter = adapterFactory.create(items);
- categoriesRecyclerView.setAdapter(categoriesAdapter);
- return rootView;
- }
-
- /**
- * Checks for internet connection and then initializes the recycler view with 25 categories of the searched query
- * Clearing categoryAdapter every time new keyword is searched so that user can see only new results
- */
- public void initSubCategoryList() {
- categoriesNotFoundView.setVisibility(GONE);
- if(!NetworkUtils.isInternetConnectionEstablished(getContext())) {
- handleNoInternet();
- return;
- }
- progressBar.setVisibility(View.VISIBLE);
- if (!isParentCategory){
- Observable.fromCallable(() -> mwApi.getSubCategoryList(categoryName))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .subscribe(this::handleSuccess, this::handleError);
- }else {
- Observable.fromCallable(() -> mwApi.getParentCategoryList(categoryName))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .subscribe(this::handleSuccess, this::handleError);
- }
- }
-
-
- /**
- * Handles the success scenario
- * it initializes the recycler view by adding items to the adapter
- * @param subCategoryList
- */
- private void handleSuccess(List subCategoryList) {
- if(subCategoryList == null || subCategoryList.isEmpty()) {
- initEmptyView();
- }
- else {
- progressBar.setVisibility(View.GONE);
- categoriesAdapter.addAll(subCategoryList);
- categoriesAdapter.notifyDataSetChanged();
- }
- }
-
- /**
- * Logs and handles API error scenario
- * @param throwable
- */
- private void handleError(Throwable throwable) {
- if (!isParentCategory){
- Timber.e(throwable, "Error occurred while loading queried subcategories");
- ViewUtil.showSnackbar(categoriesRecyclerView,R.string.error_loading_categories);
- }else {
- Timber.e(throwable, "Error occurred while loading queried parentcategories");
- ViewUtil.showSnackbar(categoriesRecyclerView,R.string.error_loading_categories);
- }
- }
-
- /**
- * Handles the UI updates for a empty results scenario
- */
- private void initEmptyView() {
- progressBar.setVisibility(GONE);
- categoriesNotFoundView.setVisibility(VISIBLE);
- if (!isParentCategory){
- categoriesNotFoundView.setText(getString(R.string.no_subcategory_found));
- }else {
- categoriesNotFoundView.setText(getString(R.string.no_parentcategory_found));
- }
-
- }
-
- /**
- * Handles the UI updates for no internet scenario
- */
- private void handleNoInternet() {
- progressBar.setVisibility(GONE);
- ViewUtil.showSnackbar(categoriesRecyclerView, R.string.no_internet);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
new file mode 100644
index 000000000..378a98893
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.concurrency
+
+import fr.free.nrw.commons.BuildConfig
+
+
+class BackgroundPoolExceptionHandler : ExceptionHandler {
+ /**
+ * If an exception occurs on a background thread, this handler will crash for debug builds
+ * but fail silently for release builds.
+ * @param t
+ */
+ override fun onException(t: Throwable) {
+ // Crash for debug build
+ if (BuildConfig.DEBUG) {
+ val thread = Thread {
+ throw RuntimeException(t)
+ }
+ thread.start()
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt
new file mode 100644
index 000000000..7605964bd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt
@@ -0,0 +1,40 @@
+package fr.free.nrw.commons.concurrency
+
+import java.util.concurrent.CancellationException
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadFactory
+
+
+class ExceptionAwareThreadPoolExecutor(
+ corePoolSize: Int,
+ threadFactory: ThreadFactory,
+ private val exceptionHandler: ExceptionHandler?
+) : ScheduledThreadPoolExecutor(corePoolSize, threadFactory) {
+
+ override fun afterExecute(r: Runnable, t: Throwable?) {
+ super.afterExecute(r, t)
+ var throwable = t
+
+ if (throwable == null && r is Future<*>) {
+ try {
+ if (r.isDone) {
+ r.get()
+ }
+ } catch (_: CancellationException) {
+ // ignore
+ } catch (_: InterruptedException) {
+ // ignore
+ } catch (e: ExecutionException) {
+ throwable = e.cause ?: e
+ } catch (e: Exception) {
+ throwable = e
+ }
+ }
+
+ throwable?.let {
+ exceptionHandler?.onException(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt
new file mode 100644
index 000000000..6b3d2a0f7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt
@@ -0,0 +1,7 @@
+package fr.free.nrw.commons.concurrency
+
+interface ExceptionHandler {
+
+ fun onException(t: Throwable)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt
new file mode 100644
index 000000000..46138d676
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt
@@ -0,0 +1,122 @@
+package fr.free.nrw.commons.concurrency
+
+import java.util.concurrent.Callable
+import java.util.concurrent.Executor
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+
+
+/**
+ * This class is a thread pool which provides some additional features:
+ * - it sets the thread priority to a value lower than foreground priority by default, or you can
+ * supply your own priority
+ * - it gives you a way to handle exceptions thrown in the thread pool
+ */
+class ThreadPoolService private constructor(builder: Builder) : Executor {
+ private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor(
+ builder.poolSize,
+ object : ThreadFactory {
+ private var count = 0
+ override fun newThread(r: Runnable): Thread {
+ count++
+ val t = Thread(r, "${builder.name}-$count")
+ // If the priority is specified out of range, we set the thread priority to
+ // Thread.MIN_PRIORITY
+ // It's done to prevent IllegalArgumentException and to prevent setting of
+ // improper high priority for a less priority task
+ t.priority =
+ if (
+ builder.priority > Thread.MAX_PRIORITY
+ ||
+ builder.priority < Thread.MIN_PRIORITY
+ ) {
+ Thread.MIN_PRIORITY
+ } else {
+ builder.priority
+ }
+ return t
+ }
+ },
+ builder.exceptionHandler
+ )
+
+ fun schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture