-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 79ee123c2..000000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 265d8a96d..000000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,90 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.mailmap b/.mailmap
deleted file mode 100644
index b140127f9..000000000
--- a/.mailmap
+++ /dev/null
@@ -1,5 +0,0 @@
-# See: https://git-scm.com/docs/git-shortlog#_mapping_authors
-#
-Brooke Vibber
-Brooke Vibber
-Brooke Vibber
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 000000000..debcd6f5d
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,56 @@
+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-26.0.2
+ - extra-google-m2repository
+ - extra-android-m2repository
+ - ${ANDROID_TARGET}
+ - android-25
+ - android-26
+ - 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 --stacktrace
+
+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 575aa6a32..035835839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,483 +1,5 @@
# Wikimedia Commons for Android
-## v6.0.2
-
-### What's changed
-* Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard
-* Links in the "File usages" list are now clickable and will take you to the correct page.
-* Titles for file usages are now clearer and easier to understand
-* Bug fixes and stability improvements
-
-## v6.0.1
-
-### What's changed
-* The app now supports Android 15 with an improved user interface
-* Enhanced Nearby with robust and more reliable labels
-* Bug fixes and stability improvements
-
-## v5.6.1
-
-### What's changed
-* The app no longer uploads images to Wikidata if one exists already for a given item
-* File usage displays correctly now
-* No more infinite circular progress bar on nominating an image for deletion
-* Enhanced location updates while using GPS
-* Author/uploader names are now available in Media Details for Commons licensing compliance
-* Improved usage of popups in Nearby
-* Bug fixes and stability improvements
-
-## v5.5.0
-
-### What's changed
-* Explore images will now be shown based on the map location and not at your current location
-* Enhanced Wikidata feedback message
-* Green labels in Explore map will no longer be hidden by other pins thumbnails
-* Upload wizard's language drop-down now reflects the language used in the pin label
-* Users can now pick only one image at a time while using the custom selector
-* Bug fixes and stability improvements
-
-## v5.4.1
-
-### What's changed
-* Custom picker now detects images that are already available on Commons
-* Improve credit line in image list
-* Show place cards with loaded names only in the Nearby list
-* Fix the error that occurs while loading images in Explore
-
-## v5.3.0
-
-### What's changed
-* Enable EmailAuth support
-* Explore map images no longer show "Unknown"
-* Fix crash when removing last two images of multiupload
-* Mark ❌ for closed locations (P3999) in Nearby
-* Fix two pin labels staying visible at the same time in Explore map
-* Refactoring and minor UI improvements
-
-## v5.2.0
-
-v5.2.0 boasts several new functionalities like:
-
-* A new refresh button lets you quickly reload the Nearby map
-* Bookmarks now support categories
-* Improved feedback and consistency in the user interface
-* Bug fixes and performance improvements
-
-### What's changed
-* Implement "Refresh" button to clear the cache and reload the Nearby map.
-* `CommonsApplication` migrate to kotlin & some lint fixes.
-* Revert back to MainScope for database and UI updates and make database operations thread safe.
-* Hide edit options for logged-out users in Explore screen.
-* Introduced a button to delete the current folder in custom selector.
-* Improve Unique File Name Search.
-* Migration of several modules from Java to Kotlin.
-* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins.
-* Bug fixes and enhancement of Achievements screen.
-* Show where file is being used on Commons and other wikis.
-* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices.
-* Make dialogs modal and always show the upload icon.
-* Fix unintentional deletion of subfolders and non-images by custom selector.
-* Bookmark categories.
-* Add pull down to refresh in the Contributions screen.
-* Fix race condition and lag when loading pin details, faster overlay management.
-* Show cached pins in Nearby even when internet is unavailable
-
- Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0).
-
-
-## v5.1.2
-
-### What's changed
-
-* Fix the broken category search in the explore screen
-
-## v5.1.1
-
-### What's changed
-
-* Use Android's new EXIF interface to mitigate security issues in old
- EXIF interface.
-* Make the icon that helps view the upload queue always visible as it ensures
- that the queue accessible at all times.
-
-## v5.1.0
-
-### What's Changed
-
-* Enhanced **upload queue management** in the Commons app for smoother, sequential
- processing, clearer progress tracking, prevention of stuck or duplicate
- uploads. As part of this improvement, the "Limited Connection mode" has been
- removed.
-* Added an option in "Nearby" feature enabling users to **provide feedback on
- Wikidata items**. Users can report if an item doesn’t exist, is at a different
- location, or has other issues, with submissions tagged for easy tracking and
- updates.
-* Improved the "Nearby" feature by splitting the query into two parts for faster
- loading and **better performance, especially in areas with dense amount of
- places**. This update also resolves issues with pins overlapping place names.
-* Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to
- the app such as adding **"Partial Access" support**. Also includes some minor
- refactoring, and replacement of deprecated circular progress bars.
-* Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs
- appeared blank** in the Category Details screen. Resolved by optimizing view
- binding handling in the parent fragments.
-* Fixed an issue where editing depictions removed all other structured data from
- images. Now, **only depictions are updated, preserving other associated data**.
-* Fixed **map centering** in the image upload flow to **use GPS EXIF tag location**
- from pictures and ensured "Show in map app" accurately reflects this location.
-* Fixed navigation **after uploading via Nearby by directing users to the Uploads
- activity** instead of returning to Nearby, preventing confusion about needing to
- upload again.
-
-### Bug fixes and various changes
-
-* Improved the "Nearby" feature to fetch labels based on the user's preferred
- language instead of defaulting to English.
-* Added a legend to the "Nearby" feature indicating pin statuses: red for items
- without pictures, green for those with pictures, and grey for items being
- checked. A floating action button now allows users to toggle the legend's
- visibility.
-* Fixed an issue where the "Nominate for deletion" option is shown to logged out
- users, preventing app errors and crashes.
-* Updated the regex pattern that filters categories with an year in it to also
- filter the 2020s.
-* Fix an issue where past depictions were not shown as suggestions, despite
- being saved correctly.
-* Fixed an issue in custom image picker where exiting the media preview showed
- only the first image and cleared selections. Now, previously selected images
- are restored correctly after exiting the preview. This was contributed.
-* Fixed an issue in custom image picker where scrolling behavior did not
- maintain position after exiting fullscreen preview, ensuring users remain at
- the same point in their image roll unless actioned images are filtered. This
- was contributed.
-* Fixed Nearby map not showing new pins on map move by removing the 2000m scroll
- threshold and adding an 800ms debounce for smoother pin updates when the map
- is moved. Queued searches are now canceled on fragment destruction.
-* Revised author information retrieval to emphasize the custom author name from
- the metadata instead of the default registered username.
-* Enhanced notification classification to properly identify "email" type
- notifications and prompting users to check their e-mail inbox when such
- notifications are clicked.
-* Resolved a bug in the language chooser that incorrectly greyed-out previously
- selected languages, ensuring only the current language is non-selectable during
- image upload.
-* Resolved pin color update issue in "Nearby" feature where the pin colour
- failed to be updated after a successful image upload.
-
-What's listed here is only a subset of all the changes. Check the full-list of
-the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0).
-Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0)
-for an exhaustive list of changes and the various contributors who contributed the same.
-
-## v5.0.2
-
-- Enhanced multi-upload functionality with user prompts to clarify that all images would share the
- same category and depictions.
-- Show Wikidata description on currently active Nearby pin to provide more useful information.
-- Improve the visibility of map markers by dynamically adjusting their colors based on the app's
- theme. The map markers will now appear lighter when the app is in dark mode and darker when the
- app is in light mode. This change aims to enhance marker visibility and improve the overall user
- experience.
-- Added information on where user feedback is posted, helping users track existing feedback and
- monitor their own submissions.
-- Enhanced the edit location screen of the upload screen by centering the map on the picture's
- location from metadata when editing, or on the device's GPS location if metadata is unavailable,
- improving accuracy and user experience.
-- Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a
- recently uploaded image, enhancing clarity and user experience.
-- Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user
- experience by showing loading progress until the image is fully loaded.
-- Fixed an issue where caption and description fields would intermittently disappear when using
- voice input, ensuring text remains visible and stable across all entries.
-- Fixed a crash that occurred when attempting to remove multiple instances of caption/description
- fields after initially adding them.
-- Improve the text in the prompt shown when skipping login to sound more natural.
-- Modified feedback addition logic to append new sections at the bottom of the page, ensuring
- auto-archiving of sections functions correctly on the feedback page.
-- Resolved issue where the app failed to clear cookies upon logout.
-
-## v5.0.1
-
-Same as v5.0.0 except this fixes some R8 rules to ensure that the release
-variants of the app work as intended.
-
-## v5.0.0
-
-### What's Changed
-
-- Redesigned the map feature to **replace Mapbox with the osmdroid library**.
- Key elements like pin visualization and user-centered display are still
- included in this redesign. This is done to guard against possible misuse of
- the Mapbox token and, more crucially, to keep the app from becoming dependent
- on a service that charges for usage but offers a free tier.
-
- With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org).
-- Add the ability to **export locations of nearby missing pictures in GPX and
- KML formats**. This allows users to browse the locations with desired radius
- for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing
- accessibility and offline functionality.
-- **Limited the uploads via the custom image picker** to a maximum of 20.
-- Added two menu choices for **transparent image backgrounds**, giving users the
- option of either a black or white background, increasing adaptability to
- various theme settings.
-
- User customization option has been provided with the
- ability to save background color selections permanently on a per image basis.
-- Implemented functionality to **automatically resume uploads** that become
- stuck due to app termination or device reboot.
-- Added a **compass arrow in the Nearby banner** shown in the "Contributions"
- screen to guide users towards the nearest item, thus providing the missing
- directional cues. The arrow dynamically adjusts based on device rotation,
- aligning with the calculated bearing towards the target location. Further,
- the distance and direction are updated as the user moves.
-- Implemented **voice input feature** for caption and description fields,
- enabling users to dictate text directly into these fields.
-- Improved various flows in the app to **redirect users to the login page** and
- display a persistent message **if their session becomes invalid** due to a
- password change, enhancing user guidance and security measures.
-
-### Revamps and refactorings
-
-- **Revamped initial upload screen layout and the description edit screen layout**
- for enhanced user experience and ensuring better symmetry in the design.
-- **Replaced Butterknife with ViewBinding** in various places of the app.
-- Transferred essential code from **the redundant data-client module** to the
- main Commons app code, enabling its integration and facilitating the removal
- of the redundant module. Further, convert various parts of the code to Kotlin.
-- **Revamped the various location permission flows** to ensure consistency for
- the sake of a better user experience.
-
-### Bug fixes and various changes
-
-- Resolved an issue where paused uploads that were subsequently cancelled were
- still being uploaded.
-- Fixed an issue where some user information such as upload count were not
- displayed in the "Contributions" and "Profile" screens.
-- Fixed the long-standing broken *"Picture of the Day" widget* to restore its
- usability.
-- Resolved an issue where some categories were hidden at the top of Upload
- Wizard suggestions.
-- Resolved an issue where there was a grey empty screen at Upload wizard when
- the app was denied the files permission.
-- Implemented logic to bypass media in Peer Review if the current reviewer is
- also the user who uploaded the media.
-- Corrected arrow image behaviour in the first upload screen: now displays down
- arrow when details card is fully visible, aligning with expected user
- interaction.
-- Updated app icon to improve visibility and recognition on F-Droid.
-- Fixed issue causing all pictures to disappear and activity to reload fully in
- the custom image selector after marking a picture as 'not for upload', now
- ensuring only the selected picture is removed as expected.
-
-What's listed here is only a subset of all the changes. Check the full-list of
-the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0).
-Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0)
-for an exhaustive list of changes and the various contributors who contributed the same.
-
-## v4.2.1
-
-- Provide the ability to edit an image to losslessly rotate it while uploading
-- Fix a bug in v4.2.0 where the nearby places were not loading
-- Fix a bug where editing depictions was showing a progress bar indefinitely
-- In the upload screen, use different map icons to indicate if image is being uploaded with location
- metadata
-- For nearby uploads, it is no longer possible to deselect the item's category and depiction
-- The Mapbox account key used by the app has been changed
-- Category search now shows exact matches without any discrepancies
-- Various bug and crash fixes
-
-## v4.2.0
-- Dark mode colour improvements
-- Enhancements done to address location metadata loss including the metadata loss that occurs in
- latest Android versions
-- Enhancements done to address the issue where uploads get stuck in queued state
-- Fix the inability to upload via the in-app camera option
-- Provide the ability to optionally include location metadata for in-app camera uploads in case the
- device camera app does not provide location metadata
-- Use geo location URL that works consistently across all map applications
-- Fix crash when clicking on location target icon while trying to edit the location of an upload
-- Fix crash that occurs randomly while returning to the app after leaving it in the background
-- Fix crash in Sign up activity on Android version 5.0 and 5.1
-- Android 13 compatibility changes
-
-## v4.1.0
-- Location of pictures uploaded via custom picture selector are now recognized
-- Improvements to the custom picture selector
-- Ensure the WLM pictures are associated with the correct templates for each year
-- Only show pictures uploaded via app in peer review
-- Improve the variety of images show in peer review
-- Allow going to current location in location edit dialog while uploading a picture
-- Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox
-- Fixed various bugs
-
-## v4.0.5
-- Bumped min SDK to 29 to try and solve Google policy issue
-- Reverted dialog
-- Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published)
-
-## v4.0.4
-- Added dialog for Google's location policy
-
-## v4.0.3
-- Added "Report" button for Google UGC policy
-
-## v4.0.2
-- Fixed bug with wrong dates taken from EXIF
-- Fixed various crashes
-
-## v4.0.1
-- Fixed bug with no browser found
-- Updated Mapbox SDK to fix hamburger crash
-
-## v4.0.0
-- Added map showing nearby Commons pictures
-- Added custom SPARQL queries
-- Added user profiles
-- Added custom picture selector
-- Various bugfixes
-- Updated target SDK to 30
-
-## v3.1.1
-- Optimized Nearby query
-- Added Sweden's property for WLM 2021
-- Added link to wiki explaining how to contribute to WLM through app
-- Fixed various bugs and crashes
-
-## v3.1.0
-- Added Wiki Loves Monuments integration for WLM 2021
-
-## v3.0.2
-- Fixed crash when uploading high res image
-- Fixed crash when viewing images in Explore
-
-## v3.0.1
-- Pre-fill desc in Nearby uploads with Wikidata item's label + description
-- Improved ACRA crash reporting
-- Fixed various crashes
-
-## v3.0.0
-- Added Structured Data to upload workflow, users can now add depicts
-- Added Leaderboard in Achievements screen
-- Added to-do system for images with no categories/descriptions or with associated Wikipedia articles that have no pictures
-- Users can now modify and add categories to their uploads from the media details view
-- New UI for main screen
-- Limited connection mode added, users can now pause and resume uploads
-
-## v2.13.1
-- Added OpenStreetMap attribution
-- Fixed various crashes
-- Fixed SQLite error in Nearby map
-- Fixed issue with Nearby uploads not being associated with Wikidata p18
-
-## v2.13.0
-- New media details UI, ability to zoom and pan around image
-- Added suggestions for a place that needs photos if user uploads a photo that is near one of them
-- Modifications and fixes to Nearby filters based on user feedback
-- Multiple crash and bug fixes
-
-## v2.12.3
-- Fixed issue with EXIF data, including coords, being removed from uploads
-
-## v2.12.2
-- Fixed crash on startup
-
-## v2.12.1
-- Fixed issue with Nearby loading in wrong location
-- Various crash fixes
-
-## v2.12.0
-- Completed codebase overhaul
-- Added filters for place type and place state to Nearby
-- Switched to using new data client library, aimed at fixing failed uploads
-- Fixed 2FA not working
-- Fixed issues with upload date and deletion notifications
-
-## v2.11.0
-- Refactored upload process, explore/media details, and peer review to use MVP architecture
-- Refactored all AsyncTasks to use RxAndroid
-- Partial migration to Retrofit
-- Allow users to remove EXIF tags from their uploads if desired
-- Multiple crash and bug fixes
-
-## v2.10.2
-- Fixed remaining issues with date image taken
-- Fixed database crash
-
-## v2.10.1
-- Fixed "stuck before category selection screen" bug
-- Fixed notification taps
-- Fixed crash while uploading images
-- Fixed crash while loading contributions
-- Fixed sporadic issue with date image was taken
-
-## v2.10.0
-- Added option to search for places that need pictures in any location
-- Added coordinate check for images submitted via Nearby
-- Added news about ongoing campaigns
-- Easy retry for failed uploads
-- Javadocs for Nearby package
-- Optimized Nearby query for faster loading
-- Allow users to dismiss notifications
-- Various bugfixes for Explore, Notifications and Nearby
-- Fixed uploads getting stuck in "receiving shared content" phase
-- Fixed empty notifications bell icon in main screen
-
-## v2.9.0
-- New main screen UI with Nearby tab
-- New upload UI and flow
-- Multiple uploads
-- Send Log File revamp
-- Fixed issues with wrong "image taken" date
-- Fixed default zoom level in Nearby map
-- Incremented target SDK to 27, with corresponding notification channel fix
-- Removed several redundant libraries to reduce bloat
-
-## v2.8.5
-- Fixed issues with sporadic upload failures due to wrong mimeType
-
-## v2.8.4
-- Hotfix for constant upload crashes for Oreo users
-
-## v2.8.3
-- Fixed issues with session tokens not being cleared in 2FA, which should reduce p18 edit failures as well
-- Fixed crash caused by bug in fetching revert count
-- Fixed crash potentially caused by Traceur library
-
-## v2.8.2
-- Fixed bug with uploads sent via Share being given .jpeg extensions and overwriting files of the same name
-
-## v2.8.1
-- Fixed bug with category edits not being sent to server
-
-## v2.8.0
-- Fixed failed uploads by modifying auth token
-- Fixed crashes during upload by storing file temporarily
-- Added automatic Wikidata p18 edits upon Nearby upload
-- Added Explore feature to browse other Commons images, including featured images
-- Added Achievements feature to see current level and upload stats
-- Added quiz for users with high deletion rates
-- Added first run tutorial for Nearby
-- Various small improvements to ShareActivity UI
-
-## v2.7.2
-- Modified subtext for "automatically get current location" setting to emphasize that it will reveal user's location
-
-## v2.7.1
-- Fixed UI and permission issues with Nearby
-- Fixed issue with My Recent Uploads being empty
-- Fixed blank category issue when uploading directly from Nearby
-- Various crash fixes
-
-## v2.7.0
-- New Nearby Places UI with direct uploads (and associated category suggestions)
-- Added two-factor authentication login
-- Added Notifications activity to display user talk messages
-- Added real-time location tracking in Nearby
-- Added "rate us", "translate", and FB link in About
-- Improvements to UI of navigation drawer, tutorial, media details view, login activity and Settings
-- Added option to nominate picture for deletion in media details view
-- Too many bug and crash fixes to mention!
-
## v2.6.7
- Added null checks to prevent frequent crashes in ModificationsSyncAdapter
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index d8f293a25..000000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,38 +0,0 @@
-Thanks for considering to contribute to this project! A few guidelines for
-people who want to contribute their code to this software are documented in
-[this project's Wiki](https://github.com/commons-app/apps-android-commons/wiki/Contributing-Guidelines).
-If you're not sure where to start head on to [this wiki page](https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome!).
-
-Here's a gist of the guidelines,
-
-1. Make separate commits for logically separate changes
-
-2. Describe your changes well in the commit message
-
-The first line of the commit message should be a short description of what has
-changed. It is also good to prefix the first line with "area: " where the "area"
-is a filename or identifier for the general area of the code being modified.
-The body should provide a meaningful commit message.
-
-1. Write Javadocs
-
- We require contributors to include Javadocs for all new methods and classes
- submitted via PRs (after 1 May 2018). This is aimed at making it easier for
- new contributors to dive into our codebase, especially those who are new to
- Android development. A few things to note:
-
- - This should not replace the need for code that is easily-readable in
- and of itself
- - Please make sure that your Javadocs are reasonably descriptive, not just
- a copy of the method name
- - Please do not use `@author` tags - we aim for collective code ownership,
- and if needed, Git allows us to see who wrote something without needing
- to add these tags (`git blame`)
-
-2. Write tests for your code (if possible)
-
-3. Make sure the Wiki pages don't become stale by updating them (if needed)
-
-### Further reading
-
-* [Importance of good commit messages](https://blog.oozou.com/commit-messages-matter-60309983c227?gi=c550a10d0f67)
diff --git a/CREDITS b/CREDITS
index 3fe6b00d0..29264b99d 100644
--- a/CREDITS
+++ b/CREDITS
@@ -30,979 +30,12 @@ their contribution to the product.
* Bruke Mekuria Mulugeta
* Paul Hawke
* Vishan Seru
-* Abhishek Poonia
-* Ayushi Negi
-* Harisanker Pradeep
-* Hassan Ismaeel
-* Jatin Rao
-* Meghna Gupta
-* S Balakrishnan
-* Suchit Kar
-* Tanvi Dadu
-* Ujjwal Agrawal
-* Mansi Agarwal
-* Siddharth Vaish
-* Ashish Kumar
-* Ilgaz Er
-* Alicia Bendz
-* Kaartic Sivaraam
-* Vanshika Arora
-* Seán Mac Gillicuddy
3rd party open source libraries used:
* Butterknife
* GSON
* Timber
+* MapBox
3rd party open source apps from which significant code has been reused:
* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia
-
-===========================================================================
-
-The Wikimedia Commons Android app uses portions of MapBox.
-
-mapbox-gl-native copyright (c) 2014-2018 Mapbox.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-* Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in
- the documentation and/or other materials provided with the
- distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
-IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
-EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
-PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
-PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-===========================================================================
-
-Mapbox GL uses portions of Android Gesture Detectors Framework.
-
-Copyright (c) 2012, Almer Thie
-
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright notice, this
- list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimer in the documentation
- and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
-ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
-ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-===========================================================================
-
-Mapbox GL uses portions of Android Support Library.
-
-Copyright (c) 2005-2013, The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-===========================================================================
-
-Mapbox GL uses portions of Boost.
-
-Distributed under the Boost Software License, Version 1.0.
-
-http://www.boost.org/LICENSE_1_0.txt
-
-===========================================================================
-
-Mapbox GL uses portions of Clipper.
-
-Author : Angus Johnson
-Version : 6.1.3a
-Date : 22 January 2014
-Website : http://www.angusj.com
-Copyright : Angus Johnson 2010-2014
-
-License:
-Use, modification & distribution is subject to Boost Software License Ver 1.
-http://www.boost.org/LICENSE_1_0.txt
-
-Attributions:
-The code in this library is an extension of Bala Vatti's clipping algorithm:
-"A generic solution to polygon clipping"
-Communications of the ACM, Vol 35, Issue 7 (July 1992) pp 56-63.
-http://portal.acm.org/citation.cfm?id=129906
-
-Computer graphics and geometric modeling: implementation and algorithms
-By Max K. Agoston
-Springer; 1 edition (January 4, 2005)
-http://books.google.com/books?q=vatti+clipping+agoston
-
-See also:
-"Polygon Offsetting by Computing Winding Numbers"
-Paper no. DETC2005-85513 pp. 565-575
-ASME 2005 International Design Engineering Technical Conferences
-and Computers and Information in Engineering Conference (IDETC/CIE2005)
-September 24-28, 2005 , Long Beach, California, USA
-http://www.me.berkeley.edu/~mcmains/pubs/DAC05OffsetPolygon.pdf
-
-===========================================================================
-
-Mapbox GL uses portions of BugshotKit.
-
-The MIT License (MIT)
-
-Copyright (c) 2014 marcoarment
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of CSS Color Parser.
-
-(c) Dean McNamee , 2012.
-C++ port by Konstantin Käfer , 2014.
-
-https://github.com/deanm/css-color-parser-js
-https://github.com/kkaefer/css-color-parser-cpp
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to
-deal in the Software without restriction, including without limitation the
-rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-sell copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-IN THE SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of GLFW.
-
-Copyright (c) 2002-2006 Marcus Geelnard
-Copyright (c) 2006-2010 Camilla Berglund
-
-This software is provided 'as-is', without any express or implied
-warranty. In no event will the authors be held liable for any damages
-arising from the use of this software.
-
-Permission is granted to anyone to use this software for any purpose,
-including commercial applications, and to alter it and redistribute it
-freely, subject to the following restrictions:
-
-1. The origin of this software must not be misrepresented; you must not
- claim that you wrote the original software. If you use this software
- in a product, an acknowledgment in the product documentation would
- be appreciated but is not required.
-
-2. Altered source versions must be plainly marked as such, and must not
- be misrepresented as being the original software.
-
-3. This notice may not be removed or altered from any source
- distribution.
-
-===========================================================================
-
-Mapbox GL uses portions of libc++.
-
-The libc++ library is dual licensed under both the University of Illinois
-"BSD-Like" license and the MIT license. As a user of this code you may choose
-to use it under either license. As a contributor, you agree to allow your code
-to be used under both.
-
-Full text of the relevant licenses is included below.
-
-====
-
-University of Illinois/NCSA
-Open Source License
-
-Copyright (c) 2009-2015 by the contributors listed in CREDITS.TXT
-
-All rights reserved.
-
-Developed by:
-
- LLVM Team
-
- University of Illinois at Urbana-Champaign
-
- http://llvm.org
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal with
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-* Redistributions of source code must retain the above copyright notice,
- this list of conditions and the following disclaimers.
-
-* Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimers in the
- documentation and/or other materials provided with the distribution.
-
-* Neither the names of the LLVM Team, University of Illinois at
- Urbana-Champaign, nor the names of its contributors may be used to
- endorse or promote products derived from this Software without specific
- prior written permission.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE
-SOFTWARE.
-
-====
-
-Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of libcurl.
-
-COPYRIGHT AND PERMISSION NOTICE
-
-Copyright (c) 1996 - 2015, Daniel Stenberg, .
-
-All rights reserved.
-
-Permission to use, copy, modify, and distribute this software for any purpose
-with or without fee is hereby granted, provided that the above copyright
-notice and this permission notice appear in all copies.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN
-NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
-DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
-OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
-OR OTHER DEALINGS IN THE SOFTWARE.
-
-Except as contained in this notice, the name of a copyright holder shall not
-be used in advertising or otherwise to promote the sale, use or other dealings
-in this Software without prior written authorization of the copyright holder.
-
-===========================================================================
-
-Mapbox GL uses portions of libjpeg-turbo.
-
-This software is based in part on the work of the Independent JPEG Group.
-
-Copyright (C)2009-2015 D. R. Commander. All Rights Reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-- Redistributions of source code must retain the above copyright notice,
- this list of conditions and the following disclaimer.
-- Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimer in the documentation
- and/or other materials provided with the distribution.
-- Neither the name of the libjpeg-turbo Project nor the names of its
- contributors may be used to endorse or promote products derived from this
- software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS",
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
-
-TurboJPEG/LJT: this implements the TurboJPEG API using libjpeg or libjpeg-turbo
-
-===========================================================================
-
-Mapbox GL uses portions of libpng.
-
-This copy of the libpng notices is provided for your convenience. In case of
-any discrepancy between this copy and the notices in the file png.h that is
-included in the libpng distribution, the latter shall prevail.
-
-COPYRIGHT NOTICE, DISCLAIMER, and LICENSE:
-
-If you modify libpng you may insert additional notices immediately following
-this sentence.
-
-This code is released under the libpng license.
-
-libpng versions 1.0.7, July 1, 2000, through 1.6.18, July 23, 2015, are
-Copyright (c) 2000-2002, 2004, 2006-2015 Glenn Randers-Pehrson, and are
-distributed according to the same disclaimer and license as libpng-1.0.6
-with the following individuals added to the list of Contributing Authors:
-
- Simon-Pierre Cadieux
- Eric S. Raymond
- Mans Rullgard
- Cosmin Truta
- Gilles Vollant
- James Yu
-
-and with the following additions to the disclaimer:
-
- There is no warranty against interference with your enjoyment of the
- library or against infringement. There is no warranty that our
- efforts or the library will fulfill any of your particular purposes
- or needs. This library is provided with all faults, and the entire
- risk of satisfactory quality, performance, accuracy, and effort is with
- the user.
-
-libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are
-Copyright (c) 1998-2000 Glenn Randers-Pehrson, and are distributed according
-to the same disclaimer and license as libpng-0.96, with the following
-individuals added to the list of Contributing Authors:
-
- Tom Lane
- Glenn Randers-Pehrson
- Willem van Schaik
-
-libpng versions 0.89, June 1996, through 0.96, May 1997, are
-Copyright (c) 1996-1997 Andreas Dilger, and are
-distributed according to the same disclaimer and license as libpng-0.88,
-with the following individuals added to the list of Contributing Authors:
-
- John Bowler
- Kevin Bracey
- Sam Bushell
- Magnus Holmgren
- Greg Roelofs
- Tom Tanner
-
-libpng versions 0.5, May 1995, through 0.88, January 1996, are
-Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc.
-
-For the purposes of this copyright and license, "Contributing Authors"
-is defined as the following set of individuals:
-
- Andreas Dilger
- Dave Martindale
- Guy Eric Schalnat
- Paul Schmidt
- Tim Wegner
-
-The PNG Reference Library is supplied "AS IS". The Contributing Authors
-and Group 42, Inc. disclaim all warranties, expressed or implied,
-including, without limitation, the warranties of merchantability and of
-fitness for any purpose. The Contributing Authors and Group 42, Inc.
-assume no liability for direct, indirect, incidental, special, exemplary,
-or consequential damages, which may result from the use of the PNG
-Reference Library, even if advised of the possibility of such damage.
-
-Permission is hereby granted to use, copy, modify, and distribute this
-source code, or portions hereof, for any purpose, without fee, subject
-to the following restrictions:
-
-1. The origin of this source code must not be misrepresented.
-
-2. Altered versions must be plainly marked as such and must not
- be misrepresented as being the original source.
-
-3. This Copyright notice may not be removed or altered from any
- source or altered source distribution.
-
-The Contributing Authors and Group 42, Inc. specifically permit, without
-fee, and encourage the use of this source code as a component to
-supporting the PNG file format in commercial products. If you use this
-source code in a product, acknowledgment is not required but would be
-appreciated.
-
-===========================================================================
-
-Mapbox GL uses portions of libuv.
-
-libuv is part of the Node project: http://nodejs.org/
-libuv may be distributed alone under Node's license:
-
-====
-
-Copyright Joyent, Inc. and other Node contributors. All rights reserved.
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to
-deal in the Software without restriction, including without limitation the
-rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-sell copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-IN THE SOFTWARE.
-
-====
-
-This license applies to all parts of libuv that are not externally
-maintained libraries.
-
-The externally maintained libraries used by libuv are:
-
-- tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license.
-
-- inet_pton and inet_ntop implementations, contained in src/inet.c, are
- copyright the Internet Systems Consortium, Inc., and licensed under the ISC
- license.
-
-- stdint-msvc2008.h (from msinttypes), copyright Alexander Chemeris. Three
- clause BSD license.
-
-- pthread-fixes.h, pthread-fixes.c, copyright Google Inc. and Sony Mobile
- Communications AB. Three clause BSD license.
-
-- android-ifaddrs.h, android-ifaddrs.c, copyright Berkeley Software Design
- Inc, Kenneth MacKay and Emergya (Cloud4all, FP7/2007-2013, grant agreement
- n° 289016). Three clause BSD license.
-
-===========================================================================
-
-Mapbox GL uses portions of libzip.
-
-Copyright (C) 1999-2014 Dieter Baron and Thomas Klausner
-
-The authors can be contacted at
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
-
-1. Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in
- the documentation and/or other materials provided with the
- distribution.
-
-3. The names of the authors may not be used to endorse or promote
- products derived from this software without specific prior
- written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
-OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
-GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
-IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
-OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
-IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-===========================================================================
-
-Mapbox GL uses portions of LOST.
-
-Copyright (c) 2014 Mapzen
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-===========================================================================
-
-Mapbox GL uses portions of the Mapbox iOS SDK, which was derived from the
-Route-Me open source project, including the Alpstein fork of it.
-
-The Route-Me license appears below.
-
-Copyright (c) 2008-2013, Route-Me Contributors
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright notice, this
-list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above copyright notice,
-this list of conditions and the following disclaimer in the documentation
-and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
-
-===========================================================================
-
-Mapbox GL uses portions of nunicode.
-
-Copyright (c) 2013 Aleksey Tulinov
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of OkHTTP.
-
-Copyright 2014 Square, Inc.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-===========================================================================
-
-Mapbox GL uses portions of OpenSSL.
-
-LICENSE ISSUES
-==============
-
-The OpenSSL toolkit stays under a dual license, i.e. both the conditions of
-the OpenSSL License and the original SSLeay license apply to the toolkit.
-See below for the actual license texts. Actually both licenses are BSD-style
-Open Source licenses. In case of any license issues related to OpenSSL
-please contact openssl-core@openssl.org.
-
-OpenSSL License
----------------
-
-Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
-
-1. Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in
- the documentation and/or other materials provided with the
- distribution.
-
-3. All advertising materials mentioning features or use of this
- software must display the following acknowledgment:
- "This product includes software developed by the OpenSSL Project
- for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
-
-4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to
- endorse or promote products derived from this software without
- prior written permission. For written permission, please contact
- openssl-core@openssl.org.
-
-5. Products derived from this software may not be called "OpenSSL"
- nor may "OpenSSL" appear in their names without prior written
- permission of the OpenSSL Project.
-
-6. Redistributions of any form whatsoever must retain the following
- acknowledgment:
- "This product includes software developed by the OpenSSL Project
- for use in the OpenSSL Toolkit (http://www.openssl.org/)"
-
-THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY
-EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR
-ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
-NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
-STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
-OF THE POSSIBILITY OF SUCH DAMAGE.
-
-This product includes cryptographic software written by Eric Young
-(eay@cryptsoft.com). This product includes software written by Tim
-Hudson (tjh@cryptsoft.com).
-
-Original SSLeay License
------------------------
-
-Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com)
-All rights reserved.
-
-This package is an SSL implementation written
-by Eric Young (eay@cryptsoft.com).
-The implementation was written so as to conform with Netscapes SSL.
-
-This library is free for commercial and non-commercial use as long as
-The following conditions are aheared to. The following conditions
-apply to all code found in this distribution, be it the RC4, RSA,
-lhash, DES, etc., code; not just the SSL code. The SSL documentation
-included with this distribution is covered by the same copyright terms
-except that the holder is Tim Hudson (tjh@cryptsoft.com).
-
-Copyright remains Eric Young's, and as such any Copyright notices in
-the code are not to be removed.
-If this package is used in a product, Eric Young should be given attribution
-as the author of the parts of the library used.
-This can be in the form of a textual message at program startup or
-in documentation (online or textual) provided with the package.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
-1. Redistributions of source code must retain the copyright
- notice, this list of conditions and the following disclaimer.
-2. Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in the
- documentation and/or other materials provided with the distribution.
-3. All advertising materials mentioning features or use of this software
- must display the following acknowledgement:
- "This product includes cryptographic software written by
- Eric Young (eay@cryptsoft.com)"
- The word 'cryptographic' can be left out if the rouines from the library
- being used are not cryptographic related :-).
-4. If you include any Windows specific code (or a derivative thereof) from
- the apps directory (application code) you must include an acknowledgement:
- "This product includes software written by Tim Hudson (tjh@cryptsoft.com)"
-
-THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.
-
-The licence and distribution terms for any publically available version or
-derivative of this code cannot be changed. i.e. this code cannot simply be
-copied and put under another distribution licence
-[including the GNU Public Licence.]
-
-===========================================================================
-
-Mapbox GL uses portions of RapidJSON.
-
-Tencent is pleased to support the open source community by making RapidJSON
-available.
-
-Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights
-reserved.
-
-If you have downloaded a copy of the RapidJSON binary from Tencent, please note
-that the RapidJSON binary is licensed under the MIT License. If you have
-downloaded a copy of the RapidJSON source code from Tencent, please note that
-RapidJSON source code is licensed under the MIT License, except for the third-
-party components listed below which are subject to different license terms.
-Your integration of RapidJSON into your own projects may require compliance with
-the MIT License, as well as the other licenses applicable to the third-party
-components included within RapidJSON. To avoid the problematic JSON license in
-your own projects, it's sufficient to exclude the bin/jsonchecker/ directory, as
-it's the only code under the JSON license. A copy of the MIT License is included
-in this file.
-
-Other dependencies and licenses:
-
-Open Source Software Licensed Under the BSD License:
---------------------------------------------------------------------
-
-The msinttypes r29
-Copyright (c) 2006-2013 Alexander Chemeris
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright notice,
- this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimer in the documentation
- and/or other materials provided with the distribution.
-* Neither the name of copyright holder nor the names of its contributors may be
- used to endorse or promote products derived from this software without
- specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
-ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-Open Source Software Licensed Under the JSON License:
---------------------------------------------------------------------
-
-json.org
-Copyright (c) 2002 JSON.org
-All Rights Reserved.
-
-JSON_checker
-Copyright (c) 2002 JSON.org
-All Rights Reserved.
-
-Terms of the JSON License:
----------------------------------------------------
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-The Software shall be used for Good, not Evil.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-Terms of the MIT License:
---------------------------------------------------------------------
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of Reachability.
-
-Copyright (c) 2011, Tony Million.
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-1. Redistributions of source code must retain the above copyright notice, this
-list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright notice,
-this list of conditions and the following disclaimer in the documentation
-and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
-
-===========================================================================
-
-Mapbox GL uses portions of SQLite.
-
-2001 September 15
-
-The author disclaims copyright to this source code. In place of
-a legal notice, here is a blessing:
-
- May you do good and not evil.
- May you find forgiveness for yourself and forgive others.
- May you share freely, never taking more than you give.
-
-===========================================================================
-
-Mapbox GL uses portions of SVPulsingAnnotationView.
-
-Copyright (c) 2013, Sam Vermette
-
-Permission to use, copy, modify, and/or distribute this software for any purpose
-with or without fee is hereby granted, provided that the above copyright notice
-and this permission notice appear in all copies.
-
-THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
-FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
-OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
-TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
-THIS SOFTWARE.
-
-===========================================================================
-
-Mapbox GL uses portions of zlib.
-
-Acknowledgments:
-
-The deflate format used by zlib was defined by Phil Katz. The deflate and
-zlib specifications were written by L. Peter Deutsch. Thanks to all the
-people who reported problems and suggested various improvements in zlib; they
-are too numerous to cite here.
-
-Copyright notice:
-
-(C) 1995-2013 Jean-loup Gailly and Mark Adler
-
-This software is provided 'as-is', without any express or implied
-warranty. In no event will the authors be held liable for any damages
-arising from the use of this software.
-
-Permission is granted to anyone to use this software for any purpose,
-including commercial applications, and to alter it and redistribute it
-freely, subject to the following restrictions:
-
-1. The origin of this software must not be misrepresented; you must not
- claim that you wrote the original software. If you use this software
- in a product, an acknowledgment in the product documentation would be
- appreciated but is not required.
-2. Altered source versions must be plainly marked as such, and must not be
- misrepresented as being the original software.
-3. This notice may not be removed or altered from any source distribution.
-
- Jean-loup Gailly Mark Adler
- jloup@gzip.org madler@alumni.caltech.edu
-
-===========================================================================
-
-Mapbox GL uses portions of Realm Objective-C.
-
-Copyright 2015 Realm Inc.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
diff --git a/README.md b/README.md
index 37f1a7872..696201029 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,36 @@
-# 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)
+# Wikimedia Commons Android app [](https://travis-ci.org/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
-Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
+We try to have an extensive documentation at [our wiki here at Github][5]:
-* [User Documentation][5]
-* [Contributor Documentation][6]
- * [Volunteers Welcome!][7]
+* [User Documentation][6]
+* [Contributor Documentation][7]
+ * [Volunteers Welcome!][9]
* [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][10].
+This software is open source, licensed under the [Apache License 2.0][4].
+
[1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
[2]: https://commons-app.github.io/
-[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
-
-[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
+[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
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 000000000..9b501d367
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,160 @@
+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.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.android.volley:volley:1.0.0'
+ implementation 'ch.acra:acra:4.7.0'
+ 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.mapbox.mapboxsdk:mapbox-android-sdk:5.2.1@aar'){
+ transitive=true
+ }
+
+
+ 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:cardview-v7:$SUPPORT_LIB_VERSION"
+
+ implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
+ kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
+
+ implementation 'com.squareup.okhttp3:okhttp:3.8.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 '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 '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.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
+ androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
+
+ testImplementation 'junit:junit:4.12'
+ testImplementation 'org.robolectric:robolectric:3.4'
+ testImplementation 'org.mockito:mockito-all:1.10.19'
+
+ testImplementation 'com.squareup.okhttp3:mockwebserver:3.8.1'
+ androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.8.1'
+ androidTestImplementation "com.android.support:support-annotations:$SUPPORT_LIB_VERSION"
+ androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
+
+ 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.google.dagger:dagger:$DAGGER_VERSION"
+ implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
+ kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
+ kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
+}
+
+android {
+ compileSdkVersion project.compileSdkVersion
+ buildToolsVersion project.buildToolsVersion
+
+ useLibrary 'org.apache.http.legacy'
+
+ defaultConfig {
+ applicationId 'fr.free.nrw.commons'
+ versionCode 82
+ versionName '2.6.7'
+ setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
+
+ minSdkVersion project.minSdkVersion
+ targetSdkVersion project.targetSdkVersion
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ vectorDrawables.useSupportLibrary = true
+ }
+
+ sourceSets {
+ test.java.srcDirs += 'src/test/kotlin'
+ }
+
+ 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'
+ }
+ debug {
+ applicationIdSuffix ".debug"
+ testCoverageEnabled true
+ versionNameSuffix "-debug-" + getBranchName() + "~" + getBuildVersion()
+ }
+ }
+
+ flavorDimensions 'tier'
+ productFlavors {
+ prod {
+ buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.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", "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\""
+ dimension 'tier'
+ }
+
+ beta {
+ // What values do we need to hit the BETA versions of the site / api ?
+ buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.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", "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\""
+ 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
deleted file mode 100644
index 41788128c..000000000
--- a/app/build.gradle.kts
+++ /dev/null
@@ -1,447 +0,0 @@
-import java.util.Properties
-import java.io.ByteArrayOutputStream
-
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.jetbrains.kotlin.android)
- alias(libs.plugins.kotlin.kapt)
- alias(libs.plugins.kotlin.parcelize)
-}
-
-apply(from = "$rootDir/jacoco.gradle")
-
-val isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file("../play.p12").exists()
-
-if (isRunningOnTravisAndIsNotPRBuild) {
- apply(plugin = "com.github.triplet.play")
-}
-
-android {
- namespace = "fr.free.nrw.commons"
- compileSdk = 35
-
- defaultConfig {
- applicationId = "fr.free.nrw.commons"
- minSdk = 21
- targetSdk = 35
- versionCode = 1059
- versionName = "6.1.0"
-
- setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testInstrumentationRunnerArguments["clearPackageData"] = "true"
-
- multiDexEnabled = true
-
- vectorDrawables {
- useSupportLibrary = true
- }
- }
-
- sourceSets {
- getByName("test") {
- // Use kotlin only in tests (for now)
- java.srcDirs("src/test/kotlin")
-
- // Use main assets and resources in test
- assets.srcDirs("src/main/assets")
- resources.srcDirs("src/main/resources")
- }
- }
-
- signingConfigs {
- create("release") {
- // Configure keystore based on env vars in Travis for automated alpha builds
- if(isRunningOnTravisAndIsNotPRBuild) {
- storeFile = file("../nr-commons.keystore")
- storePassword = System.getenv("keystore_password")
- keyAlias = System.getenv("key_alias")
- keyPassword = System.getenv("key_password")
- }
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
- testProguardFile("test-proguard-rules.txt")
-
- signingConfig = signingConfigs.getByName("debug")
- if (isRunningOnTravisAndIsNotPRBuild) {
- signingConfig = signingConfigs.getByName("release")
- }
- }
- debug {
- isMinifyEnabled = false
- proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
- testProguardFile("test-proguard-rules.txt")
-
- versionNameSuffix = "-debug-" + getBranchName()
- enableUnitTestCoverage = true
- enableAndroidTestCoverage = true
- }
- }
-
- configurations.all {
- resolutionStrategy {
- force("androidx.annotation:annotation:1.1.0")
- force("com.jakewharton.timber:timber:4.7.1")
- force("androidx.fragment:fragment:1.3.6")
- }
- exclude(module = "okhttp-ws")
- }
-
- flavorDimensions += "tier"
- productFlavors {
- create("prod") {
- dimension = "tier"
- applicationId = "fr.free.nrw.commons"
-
- buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
- buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"")
- buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
- buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
- buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
- buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"")
- buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"")
- buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"")
- buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.org\"")
- buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
- buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"")
- buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"")
- buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
- buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"")
- buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"")
- buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
- buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
- buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"")
- buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"")
- buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"")
- buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\"")
- buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\"")
- buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.recentlanguages.contentprovider\"")
- buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\"")
- buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\"")
- buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.items.contentprovider\"")
- buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
- buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
- buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
- buildConfigField("String", "DEPICTS_PROPERTY", "\"P180\"")
- buildConfigField("String", "CREATOR_PROPERTY", "\"P170\"")
- }
-
- create("beta") {
- dimension = "tier"
- applicationId = "fr.free.nrw.commons.beta"
-
- // What values do we need to hit the BETA versions of the site / api ?
- buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
- buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"")
- buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
- buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
- buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
- buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"")
- buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"")
- buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"")
- buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"")
- buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
- buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"")
- buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"")
- buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
- buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"")
- buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"")
- buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
- buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
- buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"")
- buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"")
- buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"")
- buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\"")
- buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\"")
- buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.beta.recentlanguages.contentprovider\"")
- buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\"")
- buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\"")
- buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.items.contentprovider\"")
- buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
- buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
- buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
- buildConfigField("String", "DEPICTS_PROPERTY", "\"P245962\"")
- buildConfigField("String", "CREATOR_PROPERTY", "\"P253075\"")
- }
- }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
- kotlinOptions {
- jvmTarget = "17"
- }
- buildFeatures {
- buildConfig = true
- viewBinding = true
- compose = true
- }
- buildToolsVersion = buildToolsVersion
- composeOptions {
- kotlinCompilerExtensionVersion = "1.5.8"
- }
- packaging {
- jniLibs {
- excludes += listOf("META-INF/androidx.*")
- }
- resources {
- excludes += listOf(
- "META-INF/androidx.*",
- "META-INF/proguard/androidx-annotations.pro",
- "/META-INF/LICENSE.md",
- "/META-INF/LICENSE-notice.md"
- )
- }
- }
- testOptions {
- animationsDisabled = true
- unitTests {
- isReturnDefaultValues = true
- isIncludeAndroidResources = true
- }
- unitTests.all {
- it.jvmArgs("-noverify")
- }
- }
- lint {
- abortOnError = false
- disable += listOf("MissingTranslation", "ExtraTranslation")
- }
-}
-
-dependencies {
- // Utils
- implementation(libs.gson)
- implementation(libs.okhttp)
- implementation(libs.retrofit)
- implementation(libs.retrofit.converter.gson)
- implementation(libs.retrofit.adapter.rxjava)
- implementation(libs.rxandroid)
- implementation(libs.rxjava)
- implementation(libs.rxbinding)
- implementation(libs.rxbinding.appcompat)
- implementation(libs.facebook.fresco)
- implementation(libs.facebook.fresco.middleware)
- implementation(libs.apache.commons.lang3)
-
- // UI
- implementation("${libs.viewpagerindicator.library.get()}@aar")
- implementation(libs.photoview)
- implementation(libs.android.sdk)
- implementation(libs.android.plugin.scalebar)
-
- implementation(libs.timber)
- implementation(libs.android.material)
- implementation(libs.dexter)
-
- // Jetpack Compose
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.activity.compose)
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.compose.runtime)
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.ui.viewbinding)
- implementation(libs.androidx.material3)
- implementation(libs.androidx.foundation)
- implementation(libs.androidx.foundation.layout)
- androidTestImplementation(platform(libs.androidx.compose.bom))
- androidTestImplementation(libs.androidx.ui.test.junit4)
- debugImplementation(libs.androidx.ui.tooling)
- debugImplementation(libs.androidx.ui.test.manifest)
-
- implementation(libs.adapterdelegates4.kotlin.dsl.viewbinding)
- implementation(libs.adapterdelegates4.pagination)
- implementation(libs.androidx.paging.runtime.ktx)
- testImplementation(libs.androidx.paging.common.ktx)
- implementation(libs.androidx.paging.rxjava2.ktx)
- implementation(libs.androidx.recyclerview)
-
- // Logging
- implementation(libs.acra.dialog)
- implementation(libs.acra.mail)
- implementation(libs.slf4j.api)
- implementation(libs.logback.android.classic) {
- exclude(group = "com.google.android", module = "android")
- }
- implementation(libs.logging.interceptor)
-
- // Dependency injector
- implementation(libs.dagger.android)
- implementation(libs.dagger.android.support)
- kapt(libs.dagger.android.processor)
- kapt(libs.dagger.compiler)
- annotationProcessor(libs.dagger.android.processor)
-
- implementation(libs.kotlin.reflect)
-
- //Mocking
- testImplementation(libs.mockito.kotlin)
- testImplementation(libs.mockito.core)
- testImplementation(libs.powermock.module.junit)
- testImplementation(libs.powermock.api.mockito)
- testImplementation(libs.mockk)
-
- // Unit testing
- testImplementation(libs.junit)
- testImplementation(libs.robolectric)
- testImplementation(libs.androidx.test.core)
- testImplementation(libs.androidx.runner)
- testImplementation(libs.androidx.test.ext.junit)
- testImplementation(libs.androidx.test.rules)
- testImplementation(libs.mockwebserver)
- testImplementation(libs.livedata.testing.ktx)
- testImplementation(libs.androidx.core.testing)
- testImplementation(libs.junit.jupiter.api)
- testRuntimeOnly(libs.junit.jupiter.engine)
- testImplementation(libs.soloader)
- testImplementation(libs.kotlinx.coroutines.test)
- debugImplementation(libs.androidx.fragment.testing)
- testImplementation(libs.commons.io)
-
- // Android testing
- androidTestImplementation(libs.androidx.espresso.core)
- androidTestImplementation(libs.androidx.espresso.intents)
- androidTestImplementation(libs.androidx.espresso.contrib)
- androidTestImplementation(libs.androidx.runner)
- androidTestImplementation(libs.androidx.test.rules)
- androidTestImplementation(libs.androidx.test.core)
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.androidx.annotation)
- androidTestImplementation(libs.mockwebserver)
- androidTestImplementation(libs.androidx.uiautomator)
-
- // Debugging
- debugImplementation(libs.leakcanary.android)
-
- // Support libraries
- implementation(libs.androidx.browser)
- implementation(libs.androidx.cardview)
- implementation(libs.androidx.constraintlayout)
- implementation(libs.androidx.exifinterface)
- implementation(libs.recyclerview.fastscroll)
-
- //swipe_layout
- implementation(libs.swipelayout.library)
-
- //Room
- implementation(libs.androidx.room.runtime)
- implementation(libs.androidx.room.ktx)
- implementation(libs.androidx.room.rxjava)
- kapt(libs.androidx.room.compiler)
-
- // Preferences
- implementation(libs.androidx.preference)
- implementation(libs.androidx.preference.ktx)
-
- //Android Media
- implementation(libs.juanitobananas.androidDmediaUtil)
- implementation(libs.androidx.multidex)
-
- // Kotlin + coroutines
- implementation(libs.androidx.work.runtime.ktx)
- implementation(libs.androidx.work.runtime)
- implementation(libs.kotlinx.coroutines.rx2)
- testImplementation(libs.androidx.work.testing)
-
- //Glide
- implementation(libs.glide)
- annotationProcessor(libs.glide.compiler)
- kaptTest(libs.androidx.databinding.compiler)
- kaptAndroidTest(libs.androidx.databinding.compiler)
-
- implementation(libs.coordinates2country.android) {
- exclude(group = "com.google.android", module = "android")
- }
-
- //OSMDroid
- implementation(libs.osmdroid.android)
- constraints {
- implementation(libs.kotlin.stdlib.jdk7) {
- because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
- }
- implementation(libs.kotlin.stdlib.jdk8) {
- because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
- }
- }
-}
-
-tasks.register("disableAnimations") {
- val adb = "${System.getenv("ANDROID_HOME")}/platform-tools/adb"
- commandLine(adb, "shell", "settings", "put", "global", "window_animation_scale", "0")
- commandLine(adb, "shell", "settings", "put", "global", "transition_animation_scale", "0")
- commandLine(adb, "shell", "settings", "put", "global", "animator_duration_scale", "0")
-}
-
-project.gradle.taskGraph.whenReady {
- val connectedBetaDebugAndroidTest = tasks.named("connectedBetaDebugAndroidTest")
- val connectedProdDebugAndroidTest = tasks.named("connectedProdDebugAndroidTest")
-
- connectedBetaDebugAndroidTest.configure {
- dependsOn("disableAnimations")
- }
- connectedProdDebugAndroidTest.configure {
- dependsOn("disableAnimations")
- }
-}
-
-fun getTestUserName(): String? {
- val propFile = rootProject.file("./local.properties")
- val properties = Properties()
- propFile.inputStream().use { properties.load(it) }
- return properties.getProperty("TEST_USER_NAME")
-}
-
-fun getTestPassword(): String? {
- val propFile = rootProject.file("./local.properties")
- val properties = Properties()
- propFile.inputStream().use { properties.load(it) }
- return properties.getProperty("TEST_USER_PASSWORD")
-}
-
-if (isRunningOnTravisAndIsNotPRBuild) {
- configure {
- track = "alpha"
- userFraction = 1.0
- serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
- serviceAccountCredentials = file("../play.p12")
-
- resolutionStrategy = "auto"
- outputProcessor { // this: ApkVariantOutput
- versionNameOverride = "$versionNameOverride.$versionCode"
- }
- }
-}
-
-fun getBuildVersion(): String? {
- return try {
- val stdout = ByteArrayOutputStream()
- exec {
- commandLine("git", "rev-parse", "--short", "HEAD")
- standardOutput = stdout
- }
- stdout.toString().trim()
- } catch (e: Exception) {
- null
- }
-}
-
-fun getBranchName(): String? {
- return try {
- val stdout = ByteArrayOutputStream()
- exec {
- commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
- standardOutput = stdout
- }
- stdout.toString().trim()
- } catch (e: Exception) {
- null
- }
-}
diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt
index 21c584ba9..bbf3a3f0d 100644
--- a/app/proguard-rules.txt
+++ b/app/proguard-rules.txt
@@ -1,100 +1,5 @@
-dontobfuscate
--ignorewarnings
-
--dontnote **
--dontwarn net.bytebuddy.**
--dontwarn org.mockito.**
-
-# --- Apache ---
-keep class org.apache.http.** { *; }
--dontwarn org.apache.**
-# --- /Apache ---
-
-# --- Butter Knife ---
-# Finder.castParam() is stripped when not needed and ProGuard notes it
-# unnecessarily. When castParam() is needed, it's not stripped. e.g.:
-#
-# @OnItemSelected(value = R.id.history_entry_list)
-# void foo(ListView bar) {
-# L.d("baz");
-# }
-
--dontnote butterknife.internal.**
-# --- /Butter Knife ---
-
-# --- Retrofit2 ---
-# Platform calls Class.forName on types which do not exist on Android to determine platform.
--dontnote retrofit2.Platform
-# Platform used when running on Java 8 VMs. Will not be used at runtime.
--dontwarn retrofit2.Platform$Java8
-# Retain generic type information for use by reflection by converters and adapters.
--keepattributes Signature
-# Retain declared checked exceptions for use by a Proxy instance.
--keepattributes Exceptions
-
-# Note: The model package right now seems to include some other classes that
-# are not used for serialization / deserialization over Gson. Hopefully
-# that's not a problem since it only prevents R8 from avoiding trimming
-# of few more classes.
--keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; }
--keepclasseswithmembers class fr.free.nrw.commons.actions.** { *; }
--keepclasseswithmembers class fr.free.nrw.commons.auth.csrf.** { *; }
--keepclasseswithmembers class fr.free.nrw.commons.auth.login.** { *; }
--keepclasseswithmembers class fr.free.nrw.commons.wikidata.mwapi.** { *; }
-
-# --- /Retrofit ---
-
-# --- OkHttp + Okio ---
--dontwarn okhttp3.**
--dontwarn okio.**
-# --- /OkHttp + Okio ---
-
-# --- Gson ---
-# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
-
-# Gson uses generic type information stored in a class file when working with fields. Proguard
-# removes such information by default, so configure it to keep all of it.
--keepattributes Signature
-
-# For using GSON @Expose annotation
--keepattributes *Annotation*
-
-# Gson specific classes
--dontwarn sun.misc.**
-#-keep class com.google.gson.stream.** { *; }
-
-# Application classes that will be serialized/deserialized over Gson
--keep class com.google.gson.examples.android.model.** { *; }
-
-# Prevent R8 from obfuscating project classes used by Gson for parsing
--keep class fr.free.nrw.commons.fileusages.** { *; }
-
-# Prevent proguard from stripping interface information from TypeAdapterFactory,
-# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
--keep class * implements com.google.gson.TypeAdapterFactory
--keep class * implements com.google.gson.JsonSerializer
--keep class * implements com.google.gson.JsonDeserializer
-# --- /Gson ---
-
-
-# --- /logback ---
-
--keep class ch.qos.** { *; }
--keep class org.slf4j.** { *; }
--keepattributes *Annotation*
-
--dontwarn ch.qos.logback.core.net.*
-
-# --- /acra ---
--keep class org.acra.** { *; }
--keepattributes SourceFile,LineNumberTable
--keepattributes *Annotation*
-
-# --- /recycler view ---
--keep class androidx.recyclerview.widget.RecyclerView {
- public androidx.recyclerview.widget.RecyclerView$ViewHolder findViewHolderForPosition(int);
-}
-# --- Parcelable ---
--keepclassmembers class * implements android.os.Parcelable {
- static ** CREATOR;
-}
+-dontwarn org.apache.http.**
+-keep class fr.free.nrw.commons.upload.MwVolleyApi$Page {*;}
+-keep class android.support.v7.widget.ShareActionProvider { *; }
\ No newline at end of file
diff --git a/app/quality.gradle b/app/quality.gradle
new file mode 100644
index 000000000..7ea20916a
--- /dev/null
+++ b/app/quality.gradle
@@ -0,0 +1,45 @@
+apply plugin: 'checkstyle'
+apply plugin: 'pmd'
+
+check.dependsOn 'checkstyle', 'pmd'
+
+checkstyle {
+ toolVersion = '7.5.1'
+}
+
+task checkstyle(type: Checkstyle) {
+ configFile file("${project.rootDir}/script/style/checkstyle.xml")
+ source 'src'
+ include '**/*.java'
+ exclude '**/gen/**'
+
+ classpath = files()
+
+ reports {
+ html {
+ enabled true
+ destination "${project.buildDir}/reports/checkstyle/checkstyle.html"
+ }
+ }
+}
+
+task pmd(type: Pmd) {
+ ignoreFailures = true
+ ruleSetFiles = files("${project.rootDir}/script/style/ruleset.xml")
+ ruleSets = []
+
+ source 'src'
+ include '**/*.java'
+ exclude '**/gen/**'
+
+ reports {
+ xml.enabled = false
+ html.enabled = true
+ xml {
+ destination "${project.buildDir}/reports/pmd/pmd.xml"
+ }
+ html {
+ destination "${project.buildDir}/reports/pmd/pmd.html"
+ }
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
deleted file mode 100644
index 50dfe8e7f..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-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
deleted file mode 100644
index 9bfc9321b..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-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
deleted file mode 100644
index 3d2fc9e48..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-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
deleted file mode 100644
index 003fc0674..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index 3f6487e47..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 69ce412b9..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-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
deleted file mode 100644
index ec132b447..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-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
new file mode 100644
index 000000000..5be309252
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.java
@@ -0,0 +1,99 @@
+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.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(Matchers.anything())
+ .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(Matchers.anything())
+ .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(Matchers.anything())
+ .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)
+ ));
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
deleted file mode 100644
index c5a91cd56..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-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
deleted file mode 100644
index ebb06e4af..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-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
deleted file mode 100644
index d3a814f2d..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index ed57709fc..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt
+++ /dev/null
@@ -1,203 +0,0 @@
-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
deleted file mode 100644
index 048d540b7..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
+++ /dev/null
@@ -1,363 +0,0 @@
-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
deleted file mode 100644
index 5956b3c02..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-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
deleted file mode 100644
index 54228bc13..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt
+++ /dev/null
@@ -1,271 +0,0 @@
-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
deleted file mode 100644
index c2906b501..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index 647c5bbda..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-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
new file mode 100644
index 000000000..d2db4614f
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java
@@ -0,0 +1,30 @@
+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
deleted file mode 100644
index 52ac18e4d..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package fr.free.nrw.commons.util
-
-import android.view.View
-import android.widget.EditText
-import androidx.appcompat.widget.AppCompatSpinner
-import androidx.test.espresso.UiController
-import androidx.test.espresso.ViewAction
-import org.hamcrest.Matcher
-
-class MyViewAction {
- companion object {
- fun typeTextInChildViewWithId(
- id: Int,
- textToBeTyped: String,
- ): ViewAction =
- object : ViewAction {
- override fun getConstraints(): Matcher? = null
-
- override fun getDescription(): String = "Click on a child view with specified id."
-
- override fun perform(
- uiController: UiController,
- view: View,
- ) {
- val v = view.findViewById(id) as EditText
- v.setText(textToBeTyped)
- }
- }
-
- fun selectSpinnerItemInChildViewWithId(
- id: Int,
- position: Int,
- ): ViewAction =
- object : ViewAction {
- override fun getConstraints(): Matcher? = null
-
- override fun getDescription(): String = "Click on a child view with specified id."
-
- override fun perform(
- uiController: UiController,
- view: View,
- ) {
- val v = view.findViewById(id) as AppCompatSpinner
- v.setSelection(position)
- }
- }
-
- fun clickItemWithId(
- id: Int,
- position: Int,
- ): ViewAction =
- object : ViewAction {
- override fun getConstraints(): Matcher? = null
-
- override fun getDescription(): String = "Click on a child view with specified id."
-
- override fun perform(
- uiController: UiController,
- view: View,
- ) {
- val v = view.findViewById(id) as View
- v.performClick()
- }
- }
- }
-}
diff --git a/app/src/beta/res/values/adapter.xml b/app/src/beta/res/values/adapter.xml
deleted file mode 100644
index 8e2257563..000000000
--- a/app/src/beta/res/values/adapter.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- fr.free.nrw.commons.beta
- fr.free.nrw.commons.beta.contributions.contentprovider
- fr.free.nrw.commons.beta.modifications.contentprovider
- fr.free.nrw.commons.beta.categories.contentprovider
-
diff --git a/app/src/beta/res/xml/shortcuts.xml b/app/src/beta/res/xml/shortcuts.xml
deleted file mode 100644
index 65a51995e..000000000
--- a/app/src/beta/res/xml/shortcuts.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/betaDebug/ic_launcher-web.png b/app/src/betaDebug/ic_launcher-web.png
deleted file mode 100644
index 5b1546360..000000000
Binary files a/app/src/betaDebug/ic_launcher-web.png and /dev/null differ
diff --git a/app/src/betaDebug/res/drawable-hdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-hdpi/ic_launcher.png
new file mode 100644
index 000000000..46c0a4202
Binary files /dev/null and b/app/src/betaDebug/res/drawable-hdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png
new file mode 100644
index 000000000..2e5499676
Binary files /dev/null and b/app/src/betaDebug/res/drawable-mdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png b/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..0f0c702ed
Binary files /dev/null and b/app/src/betaDebug/res/drawable-xhdpi/ic_launcher.png differ
diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 036d09bc5..000000000
--- a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ 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
deleted file mode 100644
index 036d09bc5..000000000
--- a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ 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
deleted file mode 100644
index 90c044ccd..000000000
Binary files a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png and /dev/null 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
deleted file mode 100644
index f826d5544..000000000
Binary files a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null 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
deleted file mode 100644
index 9b273c43f..000000000
Binary files a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index b09b8d252..000000000
Binary files a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png and /dev/null 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
deleted file mode 100644
index 5002ec69d..000000000
Binary files a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null 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
deleted file mode 100644
index 9aa2611ba..000000000
Binary files a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index d7b349b4d..000000000
Binary files a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png and /dev/null 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
deleted file mode 100644
index 9297963fd..000000000
Binary files a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null 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
deleted file mode 100644
index 59b088069..000000000
Binary files a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index d473d0aed..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png and /dev/null 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
deleted file mode 100644
index aeb616311..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null 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
deleted file mode 100644
index 0b7797049..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index e88874931..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null 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
deleted file mode 100644
index fa5017d72..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null 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
deleted file mode 100644
index 00a9e4bd5..000000000
Binary files a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/debug/res/values/placeholder_strings.xml b/app/src/debug/res/values/placeholder_strings.xml
new file mode 100644
index 000000000..dd7a60b20
--- /dev/null
+++ b/app/src/debug/res/values/placeholder_strings.xml
@@ -0,0 +1,6 @@
+
+
+ Overlay
+ Name
+ Description
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 17917666d..e262e9088 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,259 +1,160 @@
-
+ package="fr.free.nrw.commons">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
+
+
-
+
+
+
+
+
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
-
+
-
-
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
+
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/fontconfig/fonts.conf b/app/src/main/assets/fontconfig/fonts.conf
new file mode 100644
index 000000000..445d8ce5d
--- /dev/null
+++ b/app/src/main/assets/fontconfig/fonts.conf
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+ 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
new file mode 100644
index 000000000..45a038bad
Binary files /dev/null and b/app/src/main/assets/fontconfig/fonts/truetype/Ubuntu-R.ttf differ
diff --git a/app/src/main/assets/mapstyle.json b/app/src/main/assets/mapstyle.json
new file mode 100644
index 000000000..d4291cbdc
--- /dev/null
+++ b/app/src/main/assets/mapstyle.json
@@ -0,0 +1,26 @@
+{
+ "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/assets/queries/nearby_query.rq b/app/src/main/assets/queries/nearby_query.rq
new file mode 100644
index 000000000..1542233c1
--- /dev/null
+++ b/app/src/main/assets/queries/nearby_query.rq
@@ -0,0 +1,55 @@
+SELECT
+ (SAMPLE(?location) as ?location)
+ ?item
+ (SAMPLE(COALESCE(?item_label_preferred_language, ?item_label_any_language)) as ?label)
+ (SAMPLE(?classId) as ?class)
+ (SAMPLE(COALESCE(?class_label_preferred_language, ?class_label_any_language, "?")) as ?class_label)
+ (SAMPLE(COALESCE(?icon0, ?icon1)) as ?icon)
+ (SAMPLE(COALESCE(?emoji0, ?emoji1)) as ?emoji)
+ ?wikipediaArticle
+ ?commonsArticle
+ WHERE {
+ # Around given location...
+ SERVICE wikibase:around {
+ ?item wdt:P625 ?location.
+ bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
+ bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
+ }
+
+ # ... and without an image.
+ MINUS {?item wdt:P18 []}
+
+ # Get the label in the preferred language of the user, or any other language if no label is available in that language.
+ OPTIONAL {?item rdfs:label ?item_label_preferred_language. FILTER (lang(?item_label_preferred_language) = "${LANG}")}
+ OPTIONAL {?item rdfs:label ?item_label_any_language}
+
+ # Get the class label in the preferred language of the user, or any other language if no label is available in that language.
+ OPTIONAL {
+ ?item p:P31/ps:P31 ?classId.
+ OPTIONAL {?classId rdfs:label ?class_label_preferred_language. FILTER (lang(?class_label_preferred_language) = "${LANG}")}
+ OPTIONAL {?classId rdfs:label ?class_label_any_language}
+
+ # Get icon
+ OPTIONAL { ?classId wdt:P2910 ?icon0. }
+ OPTIONAL { ?classId wdt:P279*/wdt:P2910 ?icon1. }
+ # Get emoji
+ OPTIONAL { ?classId wdt:P487 ?emoji0. }
+ OPTIONAL { ?classId wdt:P279*/wdt:P487 ?emoji1. }
+ OPTIONAL {
+ ?sitelink schema:about ?item .
+ ?sitelink schema:inLanguage "en"
+ }
+ OPTIONAL {
+ ?wikipediaArticle schema:about ?item ;
+ schema:isPartOf .
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en" }
+ }
+
+ OPTIONAL {
+ ?commonsArticle schema:about ?item ;
+ schema:isPartOf .
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en" }
+ }
+ }
+ }
+ GROUP BY ?item ?wikipediaArticle ?commonsArticle
\ No newline at end of file
diff --git a/app/src/main/ic_explore-web.png b/app/src/main/ic_explore-web.png
deleted file mode 100644
index 55718bec5..000000000
Binary files a/app/src/main/ic_explore-web.png and /dev/null differ
diff --git a/app/src/main/ic_filled_star-web.png b/app/src/main/ic_filled_star-web.png
deleted file mode 100644
index 653e8bbaa..000000000
Binary files a/app/src/main/ic_filled_star-web.png and /dev/null differ
diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png
deleted file mode 100644
index c7f0bc3fe..000000000
Binary files a/app/src/main/ic_launcher-web.png and /dev/null differ
diff --git a/app/src/main/ic_settings_black-web.png b/app/src/main/ic_settings_black-web.png
deleted file mode 100644
index 0b0e6758a..000000000
Binary files a/app/src/main/ic_settings_black-web.png and /dev/null 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
new file mode 100644
index 000000000..6ed1bba1d
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
@@ -0,0 +1,36 @@
+package fr.free.nrw.commons;
+
+import android.os.Bundle;
+import android.widget.TextView;
+
+import butterknife.BindView;
+import butterknife.ButterKnife;
+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;
+
+ /**
+ * This method helps in the creation About screen
+ *
+ * @param savedInstanceState Data bundle
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_about);
+
+ ButterKnife.bind(this);
+
+ String aboutText = getString(R.string.about_license, getString(R.string.trademarked_name));
+ aboutLicenseText.setHtmlText(aboutText);
+
+ versionText.setText(BuildConfig.VERSION_NAME);
+ initDrawer();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
deleted file mode 100644
index 865ad3ddb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
+++ /dev/null
@@ -1,207 +0,0 @@
-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
deleted file mode 100644
index 28b01d603..000000000
--- a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-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
deleted file mode 100644
index 085307c3e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index c0c0b9a61..000000000
--- a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index e3a644c6a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-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
new file mode 100644
index 000000000..a6cf2d65d
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
@@ -0,0 +1,166 @@
+package fr.free.nrw.commons;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.sqlite.SQLiteDatabase;
+
+import com.facebook.drawee.backends.pipeline.Fresco;
+import com.facebook.stetho.Stetho;
+import com.squareup.leakcanary.LeakCanary;
+import com.squareup.leakcanary.RefWatcher;
+
+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.di.CommonsApplicationComponent;
+import fr.free.nrw.commons.modifications.ModifierSequenceDao;
+import fr.free.nrw.commons.utils.FileUtils;
+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 Application {
+
+ @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 Android Commons app";
+
+ public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
+
+ public static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com";
+
+ public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App (%s) Feedback";
+
+ private CommonsApplicationComponent component;
+ private RefWatcher refWatcher;
+
+
+ /**
+ * Used to declare and initialize various components and dependencies
+ */
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ ApplicationlessInjection
+ .getInstance(this)
+ .getCommonsApplicationComponent()
+ .inject(this);
+
+ Fresco.initialize(this);
+ if (setupLeakCanary() == RefWatcher.DISABLED) {
+ return;
+ }
+
+ 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
+ */
+ 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.clearAllAccounts()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(() -> {
+ Timber.d("All accounts have been removed");
+ //TODO: fix preference manager
+ defaultPrefs.edit().clear().commit();
+ applicationPrefs.edit().clear().commit();
+ applicationPrefs.edit().putBoolean("firstrun", false).apply();otherPrefs.edit().clear().commit();
+ 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
deleted file mode 100644
index 89fdaa055..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
+++ /dev/null
@@ -1,417 +0,0 @@
-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
new file mode 100644
index 000000000..e5e1b3b1b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/HandlerService.java
@@ -0,0 +1,74 @@
+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
new file mode 100644
index 000000000..db893de16
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/License.java
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 000000000..d08e314cc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/LicenseList.java
@@ -0,0 +1,127 @@
+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
deleted file mode 100644
index 5888b3f5f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MapController.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-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
new file mode 100644
index 000000000..726d787f3
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/Media.java
@@ -0,0 +1,419 @@
+package fr.free.nrw.commons;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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?
+ 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) {
+ for (String key : this.descriptions.keySet()) {
+ this.descriptions.remove(key);
+ }
+ for (String key : descriptions.keySet()) {
+ this.descriptions.put(key, descriptions.get(key));
+ }
+ }
+
+ /**
+ * 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);
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt
deleted file mode 100644
index dbe722e91..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Media.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-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
new file mode 100644
index 000000000..0927f0338
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java
@@ -0,0 +1,302 @@
+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 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.");
+ }
+
+ 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
+ */
+ 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);
+ }
+
+ // 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
deleted file mode 100644
index 970413283..000000000
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-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
new file mode 100644
index 000000000..a542cb363
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MediaThumbnailFetchTask.java
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 000000000..35d197782
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MediaWikiImageView.java
@@ -0,0 +1,122 @@
+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 (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
deleted file mode 100644
index c54c3aefb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-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
new file mode 100644
index 000000000..c4c52b1fc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/PageTitle.java
@@ -0,0 +1,97 @@
+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/Urls.kt b/app/src/main/java/fr/free/nrw/commons/Urls.kt
deleted file mode 100644
index 3eb7ee243..000000000
--- a/app/src/main/java/fr/free/nrw/commons/Urls.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-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
new file mode 100644
index 000000000..967f2cf8e
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/Utils.java
@@ -0,0 +1,162 @@
+package fr.free.nrw.commons;
+
+import android.content.Context;
+import android.preference.PreferenceManager;
+import android.support.annotation.NonNull;
+
+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;
+
+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
+ * @return string with capitalized first character
+ */
+ public static String capitalize(String string) {
+ return string.substring(0, 1).toUpperCase(Locale.getDefault()) + string.substring(1);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Fixing incorrect extension
+ * @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;
+ }
+ 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();
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/ViewHolder.java b/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
new file mode 100644
index 000000000..7181d85cc
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ViewHolder.java
@@ -0,0 +1,7 @@
+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
deleted file mode 100644
index a8ce8c79a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-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
new file mode 100644
index 000000000..7bfb22890
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
@@ -0,0 +1,56 @@
+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.theme.BaseActivity;
+
+public class WelcomeActivity extends BaseActivity {
+
+ @BindView(R.id.welcomePager) ViewPager pager;
+ @BindView(R.id.welcomePagerIndicator) CirclePageIndicator indicator;
+
+ private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
+
+ /**
+ * Initialises exiting fields and dependencies
+ *
+ * @param savedInstanceState WelcomeActivity bundled data
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_welcome);
+
+ 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() {
+ 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
deleted file mode 100644
index 0882ba117..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-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
new file mode 100644
index 000000000..a346655cf
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
@@ -0,0 +1,96 @@
+package fr.free.nrw.commons;
+
+import android.support.annotation.Nullable;
+import android.support.v4.view.PagerAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import butterknife.ButterKnife;
+import butterknife.OnClick;
+
+public class WelcomePagerAdapter extends PagerAdapter {
+ 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;
+
+ /**
+ * 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) {
+ LayoutInflater inflater = LayoutInflater.from(container.getContext());
+ ViewGroup layout = (ViewGroup) inflater.inflate(PAGE_LAYOUTS[position], container, false);
+
+ 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();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
deleted file mode 100644
index 0cb88c48b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package fr.free.nrw.commons
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.core.net.toUri
-import androidx.viewpager.widget.PagerAdapter
-import fr.free.nrw.commons.utils.UnderlineUtils.setUnderlinedText
-import fr.free.nrw.commons.utils.handleWebUrl
-
-class WelcomePagerAdapter : PagerAdapter() {
- /**
- * Gets total number of layouts
- * @return Number of layouts
- */
- override fun getCount(): Int = PAGE_LAYOUTS.size
-
- /**
- * Compares given view with provided object
- * @param view Adapter view
- * @param obj Adapter object
- * @return Equality between view and object
- */
- override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj)
-
- /**
- * Provides a way to remove an item from container
- * @param container Adapter view group container
- * @param position Index of item
- * @param obj Adapter object
- */
- override fun destroyItem(container: ViewGroup, position: Int, obj: Any) =
- container.removeView(obj as View)
-
- override fun instantiateItem(container: ViewGroup, position: Int): Any {
- val inflater = LayoutInflater.from(container.context)
- val layout = inflater.inflate(PAGE_LAYOUTS[position], container, false) as ViewGroup
-
- // If final page
- if (position == PAGE_LAYOUTS.size - 1) {
- // Add link to more information
- val moreInfo = layout.findViewById(R.id.welcomeInfo)
- setUnderlinedText(moreInfo, R.string.welcome_help_button_text)
- moreInfo.setOnClickListener {
- handleWebUrl(
- container.context,
- "https://commons.wikimedia.org/wiki/Help:Contents".toUri()
- )
- }
-
- // Handle click of finishTutorialButton ("YES!" button) inside layout
- layout.findViewById(R.id.finishTutorialButton)
- .setOnClickListener { view: View? -> (container.context as WelcomeActivity).finishTutorial() }
- }
-
- container.addView(layout)
- return layout
- }
-
- companion object {
- private val PAGE_LAYOUTS = intArrayOf(
- R.layout.welcome_wikipedia,
- R.layout.welcome_do_upload,
- R.layout.welcome_dont_upload,
- R.layout.welcome_image_example,
- R.layout.welcome_final
- )
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
deleted file mode 100644
index f49dd7705..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index a3d6de257..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
+++ /dev/null
@@ -1,201 +0,0 @@
-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
deleted file mode 100644
index 5e2651039..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-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
deleted file mode 100644
index 1dcf93edf..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-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
deleted file mode 100644
index 62934d0f2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index 0710e2551..000000000
--- a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt
+++ /dev/null
@@ -1,218 +0,0 @@
-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
new file mode 100644
index 000000000..a020c1fb7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
@@ -0,0 +1,62 @@
+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.os.Bundle;
+import android.support.annotation.Nullable;
+
+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;
+import static fr.free.nrw.commons.contributions.ContributionsContentProvider.CONTRIBUTION_AUTHORITY;
+import static fr.free.nrw.commons.modifications.ModificationsContentProvider.MODIFICATIONS_AUTHORITY;
+
+public class AccountUtil {
+
+ public static final String ACCOUNT_TYPE = "fr.free.nrw.commons";
+ private final Context context;
+
+ public AccountUtil(Context context) {
+ this.context = context;
+ }
+
+ public void createAccount(@Nullable AccountAuthenticatorResponse response,
+ String username, String password) {
+
+ Account account = new Account(username, 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, 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, CONTRIBUTION_AUTHORITY, true); // Enable sync by default!
+ ContentResolver.setSyncAutomatically(account, MODIFICATIONS_AUTHORITY, true); // Enable sync by default!
+ }
+
+ private AccountManager accountManager() {
+ 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
deleted file mode 100644
index aa86cd0d8..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-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
new file mode 100644
index 000000000..e793d5eb9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AuthenticatedActivity.java
@@ -0,0 +1,90 @@
+package fr.free.nrw.commons.auth;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerFuture;
+import android.os.Bundle;
+
+import javax.inject.Inject;
+
+import fr.free.nrw.commons.theme.NavigationBaseActivity;
+import io.reactivex.Single;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+import timber.log.Timber;
+
+import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
+import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
+
+public abstract class AuthenticatedActivity extends NavigationBaseActivity {
+
+ @Inject SessionManager sessionManager;
+
+ private String authCookie;
+
+
+ private void getAuthCookie(Account account, AccountManager accountManager) {
+ Single.fromCallable(() -> accountManager.blockingGetAuthToken(account, "", false))
+ .subscribeOn(Schedulers.io())
+ .doOnError(Timber::e)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ this:: onAuthCookieAcquired,
+ throwable -> onAuthFailure());
+ }
+
+ private void addAccount(AccountManager accountManager) {
+ Single.just(accountManager.addAccount(ACCOUNT_TYPE, null, null,
+ null, AuthenticatedActivity.this, null, null))
+ .subscribeOn(Schedulers.io())
+ .map(AccountManagerFuture::getResult)
+ .doOnEvent((bundle, throwable) -> {
+ if (!bundle.containsKey(KEY_ACCOUNT_NAME)) {
+ throw new RuntimeException("Bundle doesn't contain account-name key: "
+ + KEY_ACCOUNT_NAME);
+ }
+ })
+ .map(bundle -> bundle.getString(KEY_ACCOUNT_NAME))
+ .doOnError(Timber::e)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(s -> {
+ Account[] allAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE);
+ Account curAccount = allAccounts[0];
+ getAuthCookie(curAccount, accountManager);
+ },
+ throwable -> onAuthFailure());
+ }
+
+ protected void requestAuthToken() {
+ if (authCookie != null) {
+ onAuthCookieAcquired(authCookie);
+ return;
+ }
+ AccountManager accountManager = AccountManager.get(this);
+ Account curAccount = sessionManager.getCurrentAccount();
+ if (curAccount == null) {
+ addAccount(accountManager);
+ } else {
+ getAuthCookie(curAccount, accountManager);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ authCookie = savedInstanceState.getString("authCookie");
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString("authCookie", authCookie);
+ }
+
+ protected abstract void onAuthCookieAcquired(String authCookie);
+
+ protected abstract void onAuthFailure();
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
new file mode 100644
index 000000000..780f4310b
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
@@ -0,0 +1,267 @@
+package fr.free.nrw.commons.auth;
+
+import android.accounts.AccountAuthenticatorActivity;
+import android.app.ProgressDialog;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.support.annotation.ColorRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.StringRes;
+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 javax.inject.Inject;
+import javax.inject.Named;
+
+import butterknife.BindView;
+import butterknife.ButterKnife;
+import dagger.android.AndroidInjection;
+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 timber.log.Timber;
+
+import static android.view.KeyEvent.KEYCODE_ENTER;
+import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
+
+public class LoginActivity extends AccountAuthenticatorActivity {
+
+ public static final String PARAM_USERNAME = "fr.free.nrw.commons.login.username";
+
+ @Inject MediaWikiApi mwApi;
+ @Inject AccountUtil accountUtil;
+ @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;
+ ProgressDialog progressDialog;
+ private AppCompatDelegate delegate;
+ private LoginTextWatcher textWatcher = new LoginTextWatcher();
+
+ @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);
+ passwordEdit.addTextChangedListener(textWatcher);
+ twoFactorEdit.addTextChangedListener(textWatcher);
+ passwordEdit.setOnEditorActionListener(newLoginInputActionListener());
+
+ loginButton.setOnClickListener(view -> performLogin());
+ signupButton.setOnClickListener(view -> signUp());
+ }
+
+ @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) {
+ 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 LoginTask getLoginTask() {
+ return new LoginTask(
+ this,
+ canonicializeUsername(usernameEdit.getText().toString()),
+ passwordEdit.getText().toString(),
+ twoFactorEdit.getText().toString(),
+ accountUtil, mwApi, defaultPrefs
+ );
+ }
+
+ /**
+ * 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();
+ }
+
+ public void askUserForTwoFactorAuth() {
+ if (BuildConfig.DEBUG) {
+ twoFactorEdit.setVisibility(View.VISIBLE);
+ showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
+ } else {
+ showMessageAndCancelDialog(R.string.login_failed_2fa_not_supported);
+ }
+ }
+
+ public void showMessageAndCancelDialog(@StringRes int resId) {
+ showMessage(resId, R.color.secondaryDarkColor);
+ 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 performLogin() {
+ Timber.d("Login to start!");
+ LoginTask task = getLoginTask();
+ task.execute();
+ }
+
+ 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(View.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() != View.VISIBLE);
+ loginButton.setEnabled(enabled);
+ }
+ }
+}
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
deleted file mode 100644
index 0c9901b56..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
+++ /dev/null
@@ -1,489 +0,0 @@
-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/LoginTask.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginTask.java
new file mode 100644
index 000000000..b751dfe93
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginTask.java
@@ -0,0 +1,128 @@
+package fr.free.nrw.commons.auth;
+
+import android.accounts.AccountAuthenticatorResponse;
+import android.app.ProgressDialog;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Bundle;
+
+import java.io.IOException;
+
+import fr.free.nrw.commons.R;
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
+import timber.log.Timber;
+
+import static android.accounts.AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE;
+import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
+import static android.accounts.AccountManager.KEY_ACCOUNT_TYPE;
+import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
+
+class LoginTask extends AsyncTask {
+
+ private LoginActivity loginActivity;
+ private String username;
+ private String password;
+ private String twoFactorCode = "";
+ private AccountUtil accountUtil;
+ private MediaWikiApi mwApi;
+
+ public LoginTask(LoginActivity loginActivity, String username, String password,
+ String twoFactorCode, AccountUtil accountUtil,
+ MediaWikiApi mwApi, SharedPreferences prefs) {
+ this.loginActivity = loginActivity;
+ this.username = username;
+ this.password = password;
+ this.twoFactorCode = twoFactorCode;
+ this.accountUtil = accountUtil;
+ this.mwApi = mwApi;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ loginActivity.progressDialog = new ProgressDialog(loginActivity);
+ loginActivity.progressDialog.setIndeterminate(true);
+ loginActivity.progressDialog.setTitle(loginActivity.getString(R.string.logging_in_title));
+ loginActivity.progressDialog.setMessage(loginActivity.getString(R.string.logging_in_message));
+ loginActivity.progressDialog.setCanceledOnTouchOutside(false);
+ loginActivity.progressDialog.show();
+ }
+
+ @Override
+ protected String doInBackground(String... params) {
+ try {
+ if (twoFactorCode.isEmpty()) {
+ return mwApi.login(username, password);
+ } else {
+ return mwApi.login(username, password, twoFactorCode);
+ }
+ } catch (IOException e) {
+ // Do something better!
+ return "NetworkFailure";
+ }
+ }
+
+ @Override
+ protected void onPostExecute(String result) {
+ super.onPostExecute(result);
+ Timber.d("Login done!");
+
+ if (result.equals("PASS")) {
+ handlePassResult();
+ } else {
+ handleOtherResults(result);
+ }
+ }
+
+ private void handlePassResult() {
+ loginActivity.showSuccessAndDismissDialog();
+
+ AccountAuthenticatorResponse response = null;
+
+ Bundle extras = loginActivity.getIntent().getExtras();
+ if (extras != null) {
+ Timber.d("Bundle of extras: %s", extras);
+ response = extras.getParcelable(KEY_ACCOUNT_AUTHENTICATOR_RESPONSE);
+ if (response != null) {
+ Bundle authResult = new Bundle();
+ authResult.putString(KEY_ACCOUNT_NAME, username);
+ authResult.putString(KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
+ response.onResult(authResult);
+ }
+ }
+
+ accountUtil.createAccount(response, username, password);
+ loginActivity.startMainActivity();
+ }
+
+ /**
+ * 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
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_network);
+ } else if (result.toLowerCase().contains("nosuchuser".toLowerCase()) || result.toLowerCase().contains("noname".toLowerCase())) {
+ // Matches nosuchuser, nosuchusershort, noname
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_username);
+ loginActivity.emptySensitiveEditFields();
+ } else if (result.toLowerCase().contains("wrongpassword".toLowerCase())) {
+ // Matches wrongpassword, wrongpasswordempty
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_password);
+ loginActivity.emptySensitiveEditFields();
+ } else if (result.toLowerCase().contains("throttle".toLowerCase())) {
+ // Matches unknown throttle error codes
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_throttled);
+ } else if (result.toLowerCase().contains("userblocked".toLowerCase())) {
+ // Matches login-userblocked
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_blocked);
+ } else if (result.equals("2FA")) {
+ loginActivity.askUserForTwoFactorAuth();
+ } else {
+ // Occurs with unhandled login failure codes
+ Timber.d("Login failed with reason: %s", result);
+ loginActivity.showMessageAndCancelDialog(R.string.login_failed_generic);
+ }
+ }
+}
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
new file mode 100644
index 000000000..779b73b34
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
@@ -0,0 +1,71 @@
+package fr.free.nrw.commons.auth;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AuthenticatorException;
+import android.accounts.OperationCanceledException;
+import android.content.Context;
+
+import java.io.IOException;
+
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
+import io.reactivex.Completable;
+import io.reactivex.Observable;
+
+import static fr.free.nrw.commons.auth.AccountUtil.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... ;-)
+
+ public SessionManager(Context context, MediaWikiApi mediaWikiApi) {
+ this.context = context;
+ this.mediaWikiApi = mediaWikiApi;
+ this.currentAccount = null;
+ }
+
+ /**
+ * @return Account|null
+ */
+ public Account getCurrentAccount() {
+ if (currentAccount == null) {
+ AccountManager accountManager = AccountManager.get(context);
+ Account[] allAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE);
+ if (allAccounts.length != 0) {
+ currentAccount = allAccounts[0];
+ }
+ }
+ return currentAccount;
+ }
+
+ public Boolean revalidateAuthToken() {
+ AccountManager accountManager = AccountManager.get(context);
+ Account curAccount = getCurrentAccount();
+
+ if (curAccount == null) {
+ return false; // This should never happen
+ }
+
+ accountManager.invalidateAuthToken(ACCOUNT_TYPE, mediaWikiApi.getAuthCookie());
+ try {
+ String authCookie = accountManager.blockingGetAuthToken(curAccount, "", false);
+ mediaWikiApi.setAuthCookie(authCookie);
+ return true;
+ } catch (OperationCanceledException | NullPointerException | IOException | AuthenticatorException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ public Completable clearAllAccounts() {
+ AccountManager accountManager = AccountManager.get(context);
+ Account[] allAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE);
+ return Completable.fromObservable(Observable.fromArray(allAccounts)
+ .map(a -> accountManager.removeAccount(a, null, null).getResult()))
+ .doOnComplete(() -> currentAccount = null);
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
deleted file mode 100644
index c9eb7d2f1..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package fr.free.nrw.commons.auth
-
-import android.accounts.Account
-import android.accounts.AccountManager
-import android.content.Context
-import android.os.Build
-import android.text.TextUtils
-import 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
new file mode 100644
index 000000000..a6b66cbf6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
@@ -0,0 +1,63 @@
+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
deleted file mode 100644
index 22f557bcd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-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
new file mode 100644
index 000000000..c08e27966
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
@@ -0,0 +1,162 @@
+package fr.free.nrw.commons.auth;
+
+import android.accounts.AbstractAccountAuthenticator;
+import android.accounts.Account;
+import android.accounts.AccountAuthenticatorResponse;
+import android.accounts.AccountManager;
+import android.accounts.NetworkErrorException;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.io.IOException;
+
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
+
+import static android.accounts.AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION;
+import static android.accounts.AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE;
+import static android.accounts.AccountManager.KEY_ACCOUNT_NAME;
+import static android.accounts.AccountManager.KEY_ACCOUNT_TYPE;
+import static android.accounts.AccountManager.KEY_AUTHTOKEN;
+import static android.accounts.AccountManager.KEY_BOOLEAN_RESULT;
+import static android.accounts.AccountManager.KEY_ERROR_CODE;
+import static android.accounts.AccountManager.KEY_ERROR_MESSAGE;
+import static android.accounts.AccountManager.KEY_INTENT;
+import static fr.free.nrw.commons.auth.AccountUtil.ACCOUNT_TYPE;
+import static fr.free.nrw.commons.auth.LoginActivity.PARAM_USERNAME;
+
+public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
+
+ private final Context context;
+ private MediaWikiApi mediaWikiApi;
+
+ WikiAccountAuthenticator(Context context, MediaWikiApi mwApi) {
+ super(context);
+ this.context = context;
+ this.mediaWikiApi = mwApi;
+ }
+
+ private Bundle unsupportedOperation() {
+ Bundle bundle = new Bundle();
+ bundle.putInt(KEY_ERROR_CODE, ERROR_CODE_UNSUPPORTED_OPERATION);
+
+ // HACK: the docs indicate that this is a required key bit it's not displayed to the user.
+ bundle.putString(KEY_ERROR_MESSAGE, "");
+
+ return bundle;
+ }
+
+ private boolean supportedAccountType(@Nullable String type) {
+ return ACCOUNT_TYPE.equals(type);
+ }
+
+ @Override
+ public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
+ @NonNull String accountType, @Nullable String authTokenType,
+ @Nullable String[] requiredFeatures, @Nullable Bundle options)
+ throws NetworkErrorException {
+
+ if (!supportedAccountType(accountType)) {
+ return unsupportedOperation();
+ }
+
+ return addAccount(response);
+ }
+
+ private Bundle addAccount(AccountAuthenticatorResponse response) {
+ Intent Intent = new Intent(context, LoginActivity.class);
+ Intent.putExtra(KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
+
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(KEY_INTENT, Intent);
+
+ return bundle;
+ }
+
+ @Override
+ public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
+ @NonNull Account account, @Nullable Bundle options)
+ throws NetworkErrorException {
+ return unsupportedOperation();
+ }
+
+ @Override
+ public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
+ return unsupportedOperation();
+ }
+
+ private String getAuthCookie(String username, String password) throws IOException {
+ //TODO add 2fa support here
+ String result = mediaWikiApi.login(username, password);
+ if (result.equals("PASS")) {
+ return mediaWikiApi.getAuthCookie();
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
+ String authTokenType, Bundle options) throws NetworkErrorException {
+ // Extract the username and password from the Account Manager, and ask
+ // the server for an appropriate AuthToken.
+ final AccountManager am = AccountManager.get(context);
+ final String password = am.getPassword(account);
+ if (password != null) {
+ String authCookie;
+ try {
+ authCookie = getAuthCookie(account.name, password);
+ } catch (IOException e) {
+ // Network error!
+ e.printStackTrace();
+ throw new NetworkErrorException(e);
+ }
+ if (authCookie != null) {
+ final Bundle result = new Bundle();
+ result.putString(KEY_ACCOUNT_NAME, account.name);
+ result.putString(KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
+ result.putString(KEY_AUTHTOKEN, authCookie);
+ return result;
+ }
+ }
+
+ // If we get here, then we couldn't access the user's password - so we
+ // need to re-prompt them for their credentials. We do that by creating
+ // an intent to display our AuthenticatorActivity panel.
+ final Intent intent = new Intent(context, LoginActivity.class);
+ intent.putExtra(PARAM_USERNAME, account.name);
+ intent.putExtra(KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
+ final Bundle bundle = new Bundle();
+ bundle.putParcelable(KEY_INTENT, intent);
+ return bundle;
+ }
+
+ @Nullable
+ @Override
+ public String getAuthTokenLabel(@NonNull String authTokenType) {
+ //Note: the wikipedia app actually returns a string here....
+ //return supportedAccountType(authTokenType) ? context.getString(R.string.wikimedia) : null;
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
+ @NonNull Account account, @NonNull String[] features)
+ throws NetworkErrorException {
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(KEY_BOOLEAN_RESULT, false);
+ return bundle;
+ }
+
+ @Nullable
+ @Override
+ public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
+ @NonNull Account account, @Nullable String authTokenType,
+ @Nullable Bundle options) throws NetworkErrorException {
+ return unsupportedOperation();
+ }
+
+}
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
deleted file mode 100644
index 367989f14..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package fr.free.nrw.commons.auth
-
-import android.accounts.AbstractAccountAuthenticator
-import android.accounts.Account
-import android.accounts.AccountAuthenticatorResponse
-import android.accounts.AccountManager
-import android.accounts.NetworkErrorException
-import android.content.ContentResolver
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.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
new file mode 100644
index 000000000..6bc6de076
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
@@ -0,0 +1,30 @@
+package fr.free.nrw.commons.auth;
+
+import android.content.Intent;
+import android.os.IBinder;
+
+import javax.inject.Inject;
+
+import fr.free.nrw.commons.di.CommonsDaggerService;
+import fr.free.nrw.commons.mwapi.MediaWikiApi;
+
+import static android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT;
+
+public class WikiAccountAuthenticatorService extends CommonsDaggerService {
+
+ @Inject MediaWikiApi mwApi;
+ private WikiAccountAuthenticator wikiAccountAuthenticator = null;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ if (!intent.getAction().equals(ACTION_AUTHENTICATOR_INTENT)) {
+ return null;
+ }
+
+ if (wikiAccountAuthenticator == null) {
+ wikiAccountAuthenticator = new WikiAccountAuthenticator(this, mwApi);
+ }
+ return wikiAccountAuthenticator.getIBinder();
+ }
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
deleted file mode 100644
index 852536a48..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-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
deleted file mode 100644
index 6353e54ac..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
+++ /dev/null
@@ -1,217 +0,0 @@
-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
deleted file mode 100644
index 949f2dddb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index 84481c918..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 8aa3d17a0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index a653b8b55..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-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
deleted file mode 100644
index fb5ad14c6..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index 39cbf7c9f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index 0fb035eea..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-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
deleted file mode 100644
index 99abaeeec..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index 51f15b23c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index a9ed33abc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt
+++ /dev/null
@@ -1,226 +0,0 @@
-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
deleted file mode 100644
index e0ade52fe..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index a7cbf0e68..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-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
deleted file mode 100644
index 71a2d1ec9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index ef5bc613d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-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
deleted file mode 100644
index ab679611f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 4233d9508..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index c532ed3cc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-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
deleted file mode 100644
index d1a9ef785..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index e21e1ac8f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt
+++ /dev/null
@@ -1,203 +0,0 @@
-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
deleted file mode 100644
index aa9dcccc0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-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
deleted file mode 100644
index b1b03c71b..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-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
deleted file mode 100644
index 81ec80214..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 2fa65b2d9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-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
deleted file mode 100644
index f10e02ebc..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt
+++ /dev/null
@@ -1,151 +0,0 @@
-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
deleted file mode 100644
index b22723c0f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 66d670169..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-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
deleted file mode 100644
index 630889c01..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index a47eed8ca..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-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
deleted file mode 100644
index 5ee88d973..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-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
deleted file mode 100644
index 00c8e3228..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-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
deleted file mode 100644
index e8c61371a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt
+++ /dev/null
@@ -1,201 +0,0 @@
-package fr.free.nrw.commons.bookmarks.pictures
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.AdapterView.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
deleted file mode 100644
index 6a8f4d541..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-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
new file mode 100644
index 000000000..ff6ceece4
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java
@@ -0,0 +1,88 @@
+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 fr.free.nrw.commons.upload.MwVolleyApi;
+import timber.log.Timber;
+
+public class CacheController {
+
+ private double x, y;
+ private QuadTree> quadTree;
+ private double xMinus, xPlus, yMinus, yPlus;
+
+ private static final int EARTH_RADIUS = 6378137;
+
+ public CacheController() {
+ quadTree = new QuadTree<>(-180, -90, +180, +90);
+ }
+
+ public void setQtPoint(double decLongitude, double decLatitude) {
+ x = decLongitude;
+ y = decLatitude;
+ 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 (MwVolleyApi.GpsCatExists.getGpsCatExists()) {
+ pointCatList.addAll(MwVolleyApi.getGpsCat());
+ 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
+ public void convertCoordRange() {
+ //Position, decimal degrees
+ double lat = y;
+ double lon = x;
+
+ //offsets in meters
+ double offset = 100;
+
+ //Coordinate offsets in radians
+ double dLat = offset / EARTH_RADIUS;
+ double dLon = offset / (EARTH_RADIUS * Math.cos(Math.PI * lat / 180));
+
+ //OffsetPosition, decimal degrees
+ yPlus = lat + dLat * 180 / Math.PI;
+ yMinus = lat - dLat * 180 / Math.PI;
+ xPlus = lon + dLon * 180 / Math.PI;
+ xMinus = lon - dLon * 180 / Math.PI;
+ 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
deleted file mode 100644
index 9f94e8592..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index 1656109e7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 7a30ff5c4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-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
deleted file mode 100644
index 53013c1ae..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-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
deleted file mode 100644
index 1cbf7da1f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index cd68797e0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-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
new file mode 100644
index 000000000..417121c44
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesAdapterFactory.java
@@ -0,0 +1,24 @@
+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
deleted file mode 100644
index 47147944c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt
+++ /dev/null
@@ -1,332 +0,0 @@
-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
new file mode 100644
index 000000000..81cccdb72
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 000000000..76a70c0b3
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java
@@ -0,0 +1,353 @@
+package fr.free.nrw.commons.category;
+
+import android.content.Context;
+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.TextUtils;
+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.MwVolleyApi;
+import fr.free.nrw.commons.utils.StringSortingUtils;
+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 CategoryDao categoryDao;
+
+ private RVRendererAdapter categoriesAdapter;
+ private OnCategoriesSaveHandler onCategoriesSaveHandler;
+ private HashMap> categoriesCache;
+ private List selectedCategories = new ArrayList<>();
+
+ 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);
+
+ RxTextView.textChanges(categoriesFilter)
+ .takeUntil(RxView.detaches(categoriesFilter))
+ .debounce(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(filter -> updateCategoryList(filter.toString()));
+ return rootView;
+ }
+
+ @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() {
+ return gpsCategories()
+ .concatWith(titleCategories())
+ .concatWith(recentCategories());
+ }
+
+ private Observable gpsCategories() {
+ return Observable.fromIterable(
+ MwVolleyApi.GpsCatExists.getGpsCatExists()
+ ? MwVolleyApi.getGpsCat() : new ArrayList<>())
+ .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)
+ return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString))
+ || item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)"));
+ }
+
+ 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();
+ }
+}
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
new file mode 100644
index 000000000..f2d83d2e5
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/Category.java
@@ -0,0 +1,96 @@
+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
deleted file mode 100644
index e4bfb957a..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/Category.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-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
deleted file mode 100644
index ef4ec3d39..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index b031f12f1..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-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
new file mode 100644
index 000000000..16cf49742
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java
@@ -0,0 +1,168 @@
+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.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 {
+
+ public static final String AUTHORITY = "fr.free.nrw.commons.categories.contentprovider";
+ // 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://" + AUTHORITY + "/" + BASE_PATH);
+
+ private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
+
+ static {
+ uriMatcher.addURI(AUTHORITY, BASE_PATH, CATEGORIES);
+ uriMatcher.addURI(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
deleted file mode 100644
index f5cec0fce..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt
+++ /dev/null
@@ -1,188 +0,0 @@
-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
new file mode 100644
index 000000000..e63b04c26
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java
@@ -0,0 +1,184 @@
+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;
+ }
+
+ Category fromCursor(Cursor cursor) {
+ // Hardcoding column positions!
+ return new Category(
+ CategoryContentProvider.uriForId(cursor.getInt(0)),
+ cursor.getString(1),
+ new Date(cursor.getLong(2)),
+ cursor.getInt(3)
+ );
+ }
+
+ 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
deleted file mode 100644
index 3371da184..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
+++ /dev/null
@@ -1,194 +0,0 @@
-package fr.free.nrw.commons.category
-
-import android.annotation.SuppressLint
-import android.content.ContentProviderClient
-import android.content.ContentValues
-import android.database.Cursor
-import android.database.sqlite.SQLiteDatabase
-import android.os.RemoteException
-
-import java.util.ArrayList
-import java.util.Date
-import javax.inject.Inject
-import javax.inject.Named
-import javax.inject.Provider
-
-class CategoryDao @Inject constructor(
- @Named("category") private val clientProvider: Provider
-) {
-
- fun save(category: Category) {
- val db = clientProvider.get()
- try {
- if (category.contentUri == null) {
- category.contentUri = db.insert(
- CategoryContentProvider.BASE_URI,
- toContentValues(category)
- )
- } else {
- db.update(
- category.contentUri!!,
- toContentValues(category),
- null,
- null
- )
- }
- } catch (e: RemoteException) {
- throw RuntimeException(e)
- } finally {
- db.release()
- }
- }
-
- /**
- * Find persisted category in database, based on its name.
- *
- * @param name Category's name
- * @return category from database, or null if not found
- */
- fun find(name: String): Category? {
- var cursor: Cursor? = null
- val db = clientProvider.get()
- try {
- cursor = db.query(
- CategoryContentProvider.BASE_URI,
- ALL_FIELDS,
- "${COLUMN_NAME}=?",
- arrayOf(name),
- null
- )
- if (cursor != null && cursor.moveToFirst()) {
- return fromCursor(cursor)
- }
- } catch (e: RemoteException) {
- throw RuntimeException(e)
- } finally {
- cursor?.close()
- db.release()
- }
- return null
- }
-
- /**
- * Retrieve recently-used categories, ordered by descending date.
- *
- * @return a list containing recent categories
- */
- fun recentCategories(limit: Int): List {
- val items = ArrayList()
- var cursor: Cursor? = null
- val db = clientProvider.get()
- try {
- cursor = db.query(
- CategoryContentProvider.BASE_URI,
- ALL_FIELDS,
- null,
- emptyArray(),
- "$COLUMN_LAST_USED DESC"
- )
- while (cursor != null && cursor.moveToNext() && cursor.position < limit) {
- val category = fromCursor(cursor)
- if (category.name != null) {
- items.add(
- CategoryItem(
- category.name,
- category.description,
- category.thumbnail,
- false
- )
- )
- }
- }
- } catch (e: RemoteException) {
- throw RuntimeException(e)
- } finally {
- cursor?.close()
- db.release()
- }
- return items
- }
-
- @SuppressLint("Range")
- fun fromCursor(cursor: Cursor): Category {
- // Hardcoding column positions!
- return Category(
- CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))),
- cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
- cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)),
- cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)),
- Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))),
- cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED))
- )
- }
-
- private fun toContentValues(category: Category): ContentValues {
- return ContentValues().apply {
- put(COLUMN_NAME, category.name)
- put(COLUMN_DESCRIPTION, category.description)
- put(COLUMN_THUMBNAIL, category.thumbnail)
- put(COLUMN_LAST_USED, category.lastUsed?.time)
- put(COLUMN_TIMES_USED, category.timesUsed)
- }
- }
-
- companion object Table {
- const val TABLE_NAME = "categories"
-
- const val COLUMN_ID = "_id"
- const val COLUMN_NAME = "name"
- const val COLUMN_DESCRIPTION = "description"
- const val COLUMN_THUMBNAIL = "thumbnail"
- const val COLUMN_LAST_USED = "last_used"
- const val COLUMN_TIMES_USED = "times_used"
-
- // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
- val ALL_FIELDS = arrayOf(
- COLUMN_ID,
- COLUMN_NAME,
- COLUMN_DESCRIPTION,
- COLUMN_THUMBNAIL,
- COLUMN_LAST_USED,
- COLUMN_TIMES_USED
- )
-
- const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
-
- const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
- "$COLUMN_ID INTEGER PRIMARY KEY," +
- "$COLUMN_NAME STRING," +
- "$COLUMN_DESCRIPTION STRING," +
- "$COLUMN_THUMBNAIL STRING," +
- "$COLUMN_LAST_USED INTEGER," +
- "$COLUMN_TIMES_USED INTEGER" +
- ");"
-
- @SuppressLint("SQLiteString")
- fun onCreate(db: SQLiteDatabase) {
- db.execSQL(CREATE_TABLE_STATEMENT)
- }
-
- fun onDelete(db: SQLiteDatabase) {
- db.execSQL(DROP_TABLE_STATEMENT)
- onCreate(db)
- }
-
- @SuppressLint("SQLiteString")
- fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
- if (from == to) return
- if (from < 4) {
- // doesn't exist yet
- onUpdate(db, from + 1, to)
- } else if (from == 4) {
- // table added in version 5
- onCreate(db)
- onUpdate(db, from + 1, to)
- } else if (from == 5) {
- onUpdate(db, from + 1, to)
- } else if (from == 17) {
- db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;")
- db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;")
- onUpdate(db, from + 1, to)
- }
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
deleted file mode 100644
index fefe462a9..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt
+++ /dev/null
@@ -1,265 +0,0 @@
-package fr.free.nrw.commons.category
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.view.Menu
-import android.view.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
deleted file mode 100644
index a50f25669..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-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
deleted file mode 100644
index 22cb19172..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package fr.free.nrw.commons.category
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import fr.free.nrw.commons.BuildConfig
-import fr.free.nrw.commons.Media
-import fr.free.nrw.commons.R
-import fr.free.nrw.commons.actions.PageEditClient
-import fr.free.nrw.commons.notification.NotificationHelper
-import fr.free.nrw.commons.utils.ViewUtilWrapper
-import io.reactivex.Observable
-import io.reactivex.Single
-import javax.inject.Inject
-import javax.inject.Named
-import timber.log.Timber
-
-
-class CategoryEditHelper @Inject constructor(
- private val notificationHelper: NotificationHelper,
- @Named("commons-page-edit") val pageEditClient: PageEditClient,
- private val viewUtil: ViewUtilWrapper,
- @Named("username") private val username: String
-) {
-
- /**
- * Public interface to edit categories
- * @param context
- * @param media
- * @param categories
- * @return
- */
- fun makeCategoryEdit(
- context: Context,
- media: Media,
- categories: List,
- wikiText: String
- ): Single {
- viewUtil.showShortToast(
- context,
- context.getString(R.string.category_edit_helper_make_edit_toast)
- )
- return addCategory(media, categories, wikiText)
- .flatMapSingle { result ->
- Single.just(showCategoryEditNotification(context, media, result))
- }
- .firstOrError()
- }
-
- /**
- * Rebuilds the WikiText with new categories and post it on server
- *
- * @param media
- * @param categories to be added
- * @return
- */
- private fun addCategory(
- media: Media,
- categories: List?,
- wikiText: String
- ): Observable {
- Timber.d("thread is category adding %s", Thread.currentThread().name)
- val summary = "Adding categories"
- val buffer = StringBuilder()
-
- // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
- val wikiTextWithoutCategory: String = when {
- wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized"))
- wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category"))
- else -> ""
- }
-
- if (!categories.isNullOrEmpty()) {
- // If the categories list is empty, when reading the categories of a picture,
- // the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
- // So that after selecting some category, "None selected" should be removed from list
- for (category in categories) {
- if (category != "None selected" || !wikiText.contains("Uncategorized")) {
- buffer.append("[[Category:").append(category).append("]]\n")
- }
- }
- categories.dropWhile {
- it == "None selected"
- }
- } else {
- buffer.append("{{subst:unc}}")
- }
-
- val appendText = wikiTextWithoutCategory + buffer
- return pageEditClient.edit(media.filename!!, "$appendText\n", summary)
- }
-
- private fun showCategoryEditNotification(
- context: Context,
- media: Media,
- result: Boolean
- ): Boolean {
- val title: String
- val message: String
-
- if (result) {
- title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
- context.getString(R.string.category_edit_helper_show_edit_title_success)
-
- val categoriesInMessage = StringBuilder()
- val mediaCategoryList = media.categories
- for ((index, category) in mediaCategoryList?.withIndex()!!) {
- categoriesInMessage.append(category)
- if (index != mediaCategoryList.size - 1) {
- categoriesInMessage.append(",")
- }
- }
-
- message = context.resources.getQuantityString(
- R.plurals.category_edit_helper_show_edit_message_if,
- mediaCategoryList.size,
- categoriesInMessage.toString()
- )
- } else {
- title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
- context.getString(R.string.category_edit_helper_show_edit_title)
- message = context.getString(R.string.category_edit_helper_edit_message_else)
- }
-
- val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
- notificationHelper.showNotification(
- context,
- title,
- message,
- NOTIFICATION_EDIT_CATEGORY,
- browserIntent
- )
- return result
- }
-
- interface Callback {
- fun updateCategoryDisplay(categories: List?): Boolean
- }
-
- companion object {
- const val NOTIFICATION_EDIT_CATEGORY = 1
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
deleted file mode 100644
index 9fe811f74..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package fr.free.nrw.commons.category
-
-interface CategoryImagesCallback {
- fun viewPagerNotifyDataSetChanged()
-
- fun onMediaClicked(position: Int)
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
deleted file mode 100644
index 3888ef889..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-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
new file mode 100644
index 000000000..f6bacfb51
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java
@@ -0,0 +1,74 @@
+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
deleted file mode 100644
index d0ee8d53c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index 0322cd7b6..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package fr.free.nrw.commons.category
-
-import io.reactivex.Single
-
-abstract class ContinuationClient {
- private val continuationStore: MutableMap?> = mutableMapOf()
- private val continuationExists: MutableMap = mutableMapOf()
-
- private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true
-
- fun continuationRequest(
- prefix: String,
- name: String,
- requestFunction: (Map) -> Single,
- ): Single> {
- val key = "$prefix$name"
- return if (hasMorePagesFor(key)) {
- responseMapper(requestFunction(continuationStore[key] ?: emptyMap()), key)
- } else {
- Single.just(emptyList())
- }
- }
-
- abstract fun responseMapper(
- networkResult: Single,
- key: String? = null,
- ): Single>
-
- fun handleContinuationResponse(
- continuation: Map?,
- key: String?,
- ) {
- if (key != null) {
- continuationExists[key] =
- continuation?.let { continuation ->
- continuationStore[key] = continuation
- true
- } ?: false
- }
- }
-
- protected fun resetContinuation(
- prefix: String,
- category: String,
- ) {
- continuationExists.remove("$prefix$category")
- continuationStore.remove("$prefix$category")
- }
-
- /**
- * Remove the existing the key from continuationExists and continuationStore
- *
- * @param prefix
- * @param userName the username
- */
- protected fun resetUserContinuation(
- prefix: String,
- userName: String,
- ) {
- continuationExists.remove("$prefix$userName")
- continuationStore.remove("$prefix$userName")
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
deleted file mode 100644
index 0198c61a5..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-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
new file mode 100644
index 000000000..5899d5905
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java
@@ -0,0 +1,7 @@
+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
deleted file mode 100644
index 68200992c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package fr.free.nrw.commons.category
-
-interface OnCategoriesSaveHandler {
- fun onCategoriesSave(categories: List)
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
deleted file mode 100644
index 378a98893..000000000
--- a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 7605964bd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index 6b3d2a0f7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index 46138d676..000000000
--- a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-package fr.free.nrw.commons.concurrency
-
-import java.util.concurrent.Callable
-import java.util.concurrent.Executor
-import java.util.concurrent.ScheduledFuture
-import java.util.concurrent.ScheduledThreadPoolExecutor
-import java.util.concurrent.ThreadFactory
-import java.util.concurrent.TimeUnit
-
-
-/**
- * This class is a thread pool which provides some additional features:
- * - it sets the thread priority to a value lower than foreground priority by default, or you can
- * supply your own priority
- * - it gives you a way to handle exceptions thrown in the thread pool
- */
-class ThreadPoolService private constructor(builder: Builder) : Executor {
- private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor(
- builder.poolSize,
- object : ThreadFactory {
- private var count = 0
- override fun newThread(r: Runnable): Thread {
- count++
- val t = Thread(r, "${builder.name}-$count")
- // If the priority is specified out of range, we set the thread priority to
- // Thread.MIN_PRIORITY
- // It's done to prevent IllegalArgumentException and to prevent setting of
- // improper high priority for a less priority task
- t.priority =
- if (
- builder.priority > Thread.MAX_PRIORITY
- ||
- builder.priority < Thread.MIN_PRIORITY
- ) {
- Thread.MIN_PRIORITY
- } else {
- builder.priority
- }
- return t
- }
- },
- builder.exceptionHandler
- )
-
- fun schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture {
- return backgroundPool.schedule(callable, time, timeUnit)
- }
-
- fun schedule(runnable: Runnable): ScheduledFuture<*> {
- return schedule(runnable, 0, TimeUnit.SECONDS)
- }
-
- fun schedule(runnable: Runnable, time: Long, timeUnit: TimeUnit): ScheduledFuture<*> {
- return backgroundPool.schedule(runnable, time, timeUnit)
- }
-
- fun scheduleAtFixedRate(
- task: Runnable,
- initialDelay: Long,
- period: Long,
- timeUnit: TimeUnit
- ): ScheduledFuture<*> {
- return backgroundPool.scheduleWithFixedDelay(task, initialDelay, period, timeUnit)
- }
-
- fun executor(): ScheduledThreadPoolExecutor {
- return backgroundPool
- }
-
- fun shutdown() {
- backgroundPool.shutdown()
- }
-
- override fun execute(command: Runnable) {
- backgroundPool.execute(command)
- }
-
- /**
- * Builder class for [ThreadPoolService]
- */
- class Builder(val name: String) {
- var poolSize: Int = 1
- var priority: Int = Thread.MIN_PRIORITY
- var exceptionHandler: ExceptionHandler? = null
-
- /**
- * @param poolSize the number of threads to keep in the pool
- * @throws IllegalArgumentException if size of pool <= 0
- */
- fun setPoolSize(poolSize: Int): Builder {
- if (poolSize <= 0) {
- throw IllegalArgumentException("Pool size must be greater than 0")
- }
- this.poolSize = poolSize
- return this
- }
-
- /**
- * @param priority Priority of the threads in the service. You can supply a constant from
- * [java.lang.Thread] or
- * specify your own priority in the range 1(MIN_PRIORITY)
- * to 10(MAX_PRIORITY)
- * By default, the priority is set to [java.lang.Thread.MIN_PRIORITY]
- */
- fun setPriority(priority: Int): Builder {
- this.priority = priority
- return this
- }
-
- /**
- * @param handler The handler to use to handle exceptions in the service
- */
- fun setExceptionHandler(handler: ExceptionHandler): Builder {
- exceptionHandler = handler
- return this
- }
-
- fun build(): ThreadPoolService {
- return ThreadPoolService(this)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt
deleted file mode 100644
index b611574b0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package fr.free.nrw.commons.contributions
-
-import android.os.Parcel
-import android.os.Parcelable
-import fr.free.nrw.commons.upload.UploadResult
-
-data class ChunkInfo(
- val uploadResult: UploadResult?,
- val indexOfNextChunkToUpload: Int,
- val totalChunks: Int,
-) : Parcelable {
- constructor(parcel: Parcel) : this(
- parcel.readParcelable(UploadResult::class.java.classLoader),
- parcel.readInt(),
- parcel.readInt(),
- ) {
- }
-
- override fun writeToParcel(
- parcel: Parcel,
- flags: Int,
- ) {
- parcel.writeParcelable(uploadResult, flags)
- parcel.writeInt(indexOfNextChunkToUpload)
- parcel.writeInt(totalChunks)
- }
-
- override fun describeContents(): Int = 0
-
- companion object CREATOR : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel)
-
- override fun newArray(size: Int): Array = arrayOfNulls(size)
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java
new file mode 100644
index 000000000..00baac847
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java
@@ -0,0 +1,220 @@
+package fr.free.nrw.commons.contributions;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.support.annotation.NonNull;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+import fr.free.nrw.commons.BuildConfig;
+import fr.free.nrw.commons.CommonsApplication;
+import fr.free.nrw.commons.Media;
+import fr.free.nrw.commons.settings.Prefs;
+
+public class Contribution extends Media {
+
+ public static Creator CREATOR = new Creator() {
+ @Override
+ public Contribution createFromParcel(Parcel parcel) {
+ return new Contribution(parcel);
+ }
+
+ @Override
+ public Contribution[] newArray(int i) {
+ return new Contribution[0];
+ }
+ };
+
+ // No need to be bitwise - they're mutually exclusive
+ public static final int STATE_COMPLETED = -1;
+ public static final int STATE_FAILED = 1;
+ public static final int STATE_QUEUED = 2;
+ public static final int STATE_IN_PROGRESS = 3;
+
+ public static final String SOURCE_CAMERA = "camera";
+ public static final String SOURCE_GALLERY = "gallery";
+ public static final String SOURCE_EXTERNAL = "external";
+
+ private Uri contentUri;
+ private String source;
+ private String editSummary;
+ private Date timestamp;
+ private int state;
+ private long transferred;
+ private String decimalCoords;
+ private boolean isMultiple;
+
+ public Contribution(Uri contentUri, String filename, Uri localUri, String imageUrl, Date timestamp,
+ int state, long dataLength, Date dateUploaded, long transferred,
+ String source, String description, String creator, boolean isMultiple,
+ int width, int height, String license) {
+ super(localUri, imageUrl, filename, description, dataLength, timestamp, dateUploaded, creator);
+ this.contentUri = contentUri;
+ this.state = state;
+ this.timestamp = timestamp;
+ this.transferred = transferred;
+ this.source = source;
+ this.isMultiple = isMultiple;
+ this.width = width;
+ this.height = height;
+ this.license = license;
+ }
+
+ public Contribution(Uri localUri, String imageUrl, String filename, String description, long dataLength,
+ Date dateCreated, Date dateUploaded, String creator, String editSummary, String decimalCoords) {
+ super(localUri, imageUrl, filename, description, dataLength, dateCreated, dateUploaded, creator);
+ this.decimalCoords = decimalCoords;
+ this.editSummary = editSummary;
+ timestamp = new Date(System.currentTimeMillis());
+ }
+
+ public Contribution(Parcel in) {
+ super(in);
+ contentUri = in.readParcelable(Uri.class.getClassLoader());
+ source = in.readString();
+ timestamp = (Date) in.readSerializable();
+ state = in.readInt();
+ transferred = in.readLong();
+ isMultiple = in.readInt() == 1;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int flags) {
+ super.writeToParcel(parcel, flags);
+ parcel.writeParcelable(contentUri, flags);
+ parcel.writeString(source);
+ parcel.writeSerializable(timestamp);
+ parcel.writeInt(state);
+ parcel.writeLong(transferred);
+ parcel.writeInt(isMultiple ? 1 : 0);
+ }
+
+ public boolean getMultiple() {
+ return isMultiple;
+ }
+
+ public void setMultiple(boolean multiple) {
+ isMultiple = multiple;
+ }
+
+ public long getTransferred() {
+ return transferred;
+ }
+
+ public void setTransferred(long transferred) {
+ this.transferred = transferred;
+ }
+
+ public String getEditSummary() {
+ return editSummary != null ? editSummary : CommonsApplication.DEFAULT_EDIT_SUMMARY;
+ }
+
+ public Uri getContentUri() {
+ return contentUri;
+ }
+
+ public void setContentUri(Uri contentUri) {
+ this.contentUri = contentUri;
+ }
+
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(Date timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public int getState() {
+ return state;
+ }
+
+ public void setState(int state) {
+ this.state = state;
+ }
+
+ public void setDateUploaded(Date date) {
+ this.dateUploaded = date;
+ }
+
+ public String getTrackingTemplates() {
+ return "{{subst:unc}}"; // Remove when we have categorization
+ }
+
+ public String getPageContents() {
+ StringBuilder buffer = new StringBuilder();
+ SimpleDateFormat isoFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH);
+
+ buffer
+ .append("== {{int:filedesc}} ==\n")
+ .append("{{Information\n")
+ .append("|description=").append(getDescription()).append("\n")
+ .append("|source=").append("{{own}}\n")
+ .append("|author=[[User:").append(creator).append("|").append(creator).append("]]\n");
+ if (dateCreated != null) {
+ buffer
+ .append("|date={{According to EXIF data|").append(isoFormat.format(dateCreated)).append("}}\n");
+ }
+ buffer
+ .append("}}").append("\n");
+
+ //Only add Location template (e.g. {{Location|37.51136|-77.602615}} ) if coords is not null
+ if (decimalCoords != null) {
+ buffer.append("{{Location|").append(decimalCoords).append("}}").append("\n");
+ }
+
+ buffer.append("== {{int:license-header}} ==\n")
+ .append(licenseTemplateFor(getLicense())).append("\n\n")
+ .append("{{Uploaded from Mobile|platform=Android|version=").append(BuildConfig.VERSION_NAME).append("}}\n")
+ .append(getTrackingTemplates());
+ return buffer.toString();
+ }
+
+ @Override
+ public void setFilename(String filename) {
+ this.filename = filename;
+ }
+
+ public void setImageUrl(String imageUrl) {
+ this.imageUrl = imageUrl;
+ }
+
+ public Contribution() {
+ timestamp = new Date(System.currentTimeMillis());
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public void setSource(String source) {
+ this.source = source;
+ }
+
+ public void setLocalUri(Uri localUri) {
+ this.localUri = localUri;
+ }
+
+ @NonNull
+ private String licenseTemplateFor(String license) {
+ switch (license) {
+ case Prefs.Licenses.CC_BY_3:
+ return "{{self|cc-by-3.0}}";
+ case Prefs.Licenses.CC_BY_4:
+ return "{{self|cc-by-4.0}}";
+ case Prefs.Licenses.CC_BY_SA_3:
+ return "{{self|cc-by-sa-3.0}}";
+ case Prefs.Licenses.CC_BY_SA_4:
+ return "{{self|cc-by-sa-4.0}}";
+ case Prefs.Licenses.CC0:
+ return "{{self|cc-zero}}";
+ case Prefs.Licenses.CC_BY:
+ return "{{self|cc-by-3.0}}";
+ case Prefs.Licenses.CC_BY_SA:
+ return "{{self|cc-by-sa-3.0}}";
+ }
+ throw new RuntimeException("Unrecognized license value: " + license);
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt
deleted file mode 100644
index d623730ab..000000000
--- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-package fr.free.nrw.commons.contributions
-
-import android.net.Uri
-import android.os.Parcelable
-import androidx.room.Embedded
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import fr.free.nrw.commons.Media
-import fr.free.nrw.commons.auth.SessionManager
-import fr.free.nrw.commons.upload.UploadItem
-import fr.free.nrw.commons.upload.UploadMediaDetail
-import fr.free.nrw.commons.upload.WikidataPlace
-import fr.free.nrw.commons.upload.WikidataPlace.Companion.from
-import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
-import kotlinx.parcelize.Parcelize
-import java.io.File
-import java.util.Date
-
-@Entity(tableName = "contribution")
-@Parcelize
-data class Contribution constructor(
- @Embedded(prefix = "media_") val media: Media,
- @PrimaryKey val pageId: String = media.pageId,
- var state: Int = 0,
- var transferred: Long = 0,
- val decimalCoords: String? = null,
- var dateCreatedSource: String? = null,
- var wikidataPlace: WikidataPlace? = null,
- var chunkInfo: ChunkInfo? = null,
- var errorInfo: String? = null,
- /**
- * @return array list of entityids for the depictions
- *
- * Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id
- * is in turn used to upload depictions to wikibase
- */
- val depictedItems: List = ArrayList(),
- var mimeType: String? = null,
- val localUri: Uri? = null,
- var dataLength: Long = 0,
- var dateCreated: Date? = null,
- var dateCreatedString: String? = null,
- var dateModified: Date? = null,
- var dateUploadStarted: Date? = null,
- var hasInvalidLocation: Int = 0,
- var contentUri: Uri? = null,
- var countryCode: String? = null,
- var imageSHA1: String? = null,
- /**
- * Number of times a contribution has been retried after a failure
- */
- var retries: Int = 0,
-) : Parcelable {
- fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED)
-
- constructor(
- item: UploadItem,
- sessionManager: SessionManager,
- depictedItems: List,
- categories: List,
- imageSHA1: String,
- ) : this(
- Media(
- formatCaptions(item.uploadMediaDetails),
- categories,
- item.filename,
- formatDescriptions(item.uploadMediaDetails),
- sessionManager.userName,
- sessionManager.userName,
- ),
- localUri = item.mediaUri,
- decimalCoords = item.gpsCoords?.decimalCoords,
- dateCreatedSource = "",
- depictedItems = depictedItems,
- wikidataPlace = from(item.place),
- contentUri = item.contentUri,
- dateCreatedString = item.fileCreatedDateString,
- imageSHA1 = imageSHA1,
- )
-
- /**
- * Set this true when ImageProcessor has said that the location is invalid
- * @param hasInvalidLocation
- */
- fun setHasInvalidLocation(hasInvalidLocation: Boolean) {
- this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0
- }
-
- fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1
-
- companion object {
- const val STATE_COMPLETED = -1
- const val STATE_FAILED = 1
- const val STATE_QUEUED = 2
- const val STATE_IN_PROGRESS = 3
- const val STATE_PAUSED = 4
-
- /**
- * Formatting captions to the Wikibase format for sending labels
- * @param uploadMediaDetails list of media Details
- */
- fun formatCaptions(uploadMediaDetails: List) =
- uploadMediaDetails
- .associate { it.languageCode!! to it.captionText }
- .filter { it.value.isNotBlank() }
-
- /**
- * Formats the list of descriptions into the format Commons requires for uploads.
- *
- * @param descriptions the list of descriptions, description is ignored if text is null.
- * @return a string with the pattern of {{en|1=descriptionText}}
- */
- fun formatDescriptions(descriptions: List) =
- descriptions
- .filter { !it.descriptionText.isNullOrEmpty() }
- .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
- }
-
- val fileKey: String? get() = chunkInfo?.uploadResult?.filekey
- val localUriPath: File? get() = localUri?.path?.let { File(it) }
-
- fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
-
- fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
deleted file mode 100644
index b5075a21e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-package fr.free.nrw.commons.contributions
-
-import androidx.paging.PagedList.BoundaryCallback
-import fr.free.nrw.commons.auth.SessionManager
-import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
-import fr.free.nrw.commons.media.MediaClient
-import io.reactivex.Scheduler
-import io.reactivex.disposables.CompositeDisposable
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Named
-
-/**
- * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that
- * is triggered for various boundary conditions in the list
- */
-class ContributionBoundaryCallback
- @Inject
- constructor(
- private val repository: ContributionsRepository,
- private val sessionManager: SessionManager,
- private val mediaClient: MediaClient,
- @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler,
- ) : BoundaryCallback() {
- private val compositeDisposable: CompositeDisposable = CompositeDisposable()
- var userName: String? = null
-
- /**
- * It is triggered when the list has no items User's Contributions are then fetched from the
- * network
- */
- override fun onZeroItemsLoaded() {
- refreshList()
- }
-
- /**
- * It is triggered when the user scrolls to the top of the list
- * */
- override fun onItemAtFrontLoaded(itemAtFront: Contribution) {
- }
-
- /**
- * It is triggered when the user scrolls to the end of the list. User's Contributions are then
- * fetched from the network
- */
- override fun onItemAtEndLoaded(itemAtEnd: Contribution) {
- fetchContributions()
- }
-
- /**
- * Fetch list from network and save it to local DB.
- *
- * @param onRefreshFinish callback to invoke when operations finishes
- * with either error or success.
- */
- fun refreshList(onRefreshFinish: () -> Unit = {}){
- if (sessionManager.userName != null) {
- mediaClient.resetUserNameContinuation(sessionManager.userName!!)
- }
- fetchContributions(onRefreshFinish)
- }
-
- /**
- * Fetches contributions using the MediaWiki API
- *
- * @param onRefreshFinish callback to invoke when operations finishes
- * with either error or success.
- */
- private fun fetchContributions(onRefreshFinish: () -> Unit = {}) {
- if (sessionManager.userName != null) {
- userName
- ?.let { userName ->
- mediaClient
- .getMediaListForUser(userName)
- .map { mediaList ->
- mediaList.map { media ->
- Contribution(media = media, state = Contribution.STATE_COMPLETED)
- }
- }.subscribeOn(ioThreadScheduler)
- .subscribe({ list ->
- saveContributionsToDB(list, onRefreshFinish)
- },{ error ->
- onRefreshFinish()
- Timber.e(
- "Failed to fetch contributions: %s",
- error.message,
- )
- })
- }?.let {
- compositeDisposable.add(
- it,
- )
- }
- } else {
- compositeDisposable.clear()
- }
- }
-
- /**
- * Saves the contributions the the local DB
- *
- * @param onRefreshFinish callback to invoke when successfully saved to DB.
- */
- private fun saveContributionsToDB(contributions: List, onRefreshFinish: () -> Unit) {
- compositeDisposable.add(
- repository
- .save(contributions)
- .subscribeOn(ioThreadScheduler)
- .subscribe { longs: List? ->
- onRefreshFinish()
- repository["last_fetch_timestamp"] = System.currentTimeMillis()
- },
- )
- }
-
- /**
- * Clean up
- */
- fun dispose() {
- compositeDisposable.dispose()
- }
- }
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java
new file mode 100644
index 000000000..a243330c3
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java
@@ -0,0 +1,121 @@
+package fr.free.nrw.commons.contributions;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentActivity;
+import android.support.v4.content.FileProvider;
+
+import java.io.File;
+import java.util.Date;
+import java.util.List;
+
+import fr.free.nrw.commons.upload.ShareActivity;
+import timber.log.Timber;
+
+import static android.content.Intent.ACTION_GET_CONTENT;
+import static android.content.Intent.ACTION_SEND;
+import static android.content.Intent.EXTRA_STREAM;
+import static fr.free.nrw.commons.contributions.Contribution.SOURCE_CAMERA;
+import static fr.free.nrw.commons.contributions.Contribution.SOURCE_GALLERY;
+import static fr.free.nrw.commons.upload.UploadService.EXTRA_SOURCE;
+
+class ContributionController {
+
+ private static final int SELECT_FROM_GALLERY = 1;
+ private static final int SELECT_FROM_CAMERA = 2;
+
+ private Fragment fragment;
+
+ ContributionController(Fragment fragment) {
+ this.fragment = fragment;
+ }
+
+ // See http://stackoverflow.com/a/5054673/17865 for why this is done
+ private Uri lastGeneratedCaptureUri;
+
+ private Uri reGenerateImageCaptureUriInCache() {
+ File photoFile = new File(fragment.getContext().getCacheDir() + "/images",
+ new Date().getTime() + ".jpg");
+ photoFile.getParentFile().mkdirs();
+ Context applicationContext = fragment.getActivity().getApplicationContext();
+ return FileProvider.getUriForFile(
+ fragment.getContext(),
+ applicationContext.getPackageName() + ".provider",
+ photoFile);
+ }
+
+ private static void requestWritePermission(Context context, Intent intent, Uri uri) {
+
+ List resInfoList = context.getPackageManager().queryIntentActivities(intent,
+ PackageManager.MATCH_DEFAULT_ONLY);
+ for (ResolveInfo resolveInfo : resInfoList) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ }
+ }
+
+ void startCameraCapture() {
+
+ Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ lastGeneratedCaptureUri = reGenerateImageCaptureUriInCache();
+
+ // Intent.setFlags doesn't work for API level <20
+ requestWritePermission(fragment.getContext(), takePictureIntent, lastGeneratedCaptureUri);
+
+ takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, lastGeneratedCaptureUri);
+ fragment.startActivityForResult(takePictureIntent, SELECT_FROM_CAMERA);
+ }
+
+ public void startGalleryPick() {
+ //FIXME: Starts gallery (opens Google Photos)
+ Intent pickImageIntent = new Intent(ACTION_GET_CONTENT);
+ pickImageIntent.setType("image/*");
+ fragment.startActivityForResult(pickImageIntent, SELECT_FROM_GALLERY);
+ }
+
+ void handleImagePicked(int requestCode, Intent data) {
+ FragmentActivity activity = fragment.getActivity();
+ Intent shareIntent = new Intent(activity, ShareActivity.class);
+ shareIntent.setAction(ACTION_SEND);
+ switch (requestCode) {
+ case SELECT_FROM_GALLERY:
+ //Handles image picked from gallery
+ Uri imageData = data.getData();
+ shareIntent.setType(activity.getContentResolver().getType(imageData));
+ shareIntent.putExtra(EXTRA_STREAM, imageData);
+ shareIntent.putExtra(EXTRA_SOURCE, SOURCE_GALLERY);
+ break;
+ case SELECT_FROM_CAMERA:
+ shareIntent.setType("image/jpeg"); //FIXME: Find out appropriate mime type
+ shareIntent.putExtra(EXTRA_STREAM, lastGeneratedCaptureUri);
+ shareIntent.putExtra(EXTRA_SOURCE, SOURCE_CAMERA);
+ break;
+ }
+ Timber.i("Image selected");
+ try {
+ activity.startActivity(shareIntent);
+ } catch (SecurityException e) {
+ Timber.e(e, "Security Exception");
+ }
+ }
+
+ void saveState(Bundle outState) {
+ if (outState != null) {
+ outState.putParcelable("lastGeneratedCaptureURI", lastGeneratedCaptureUri);
+ }
+ }
+
+ void loadState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ lastGeneratedCaptureUri = savedInstanceState.getParcelable("lastGeneratedCaptureURI");
+ }
+ }
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt
deleted file mode 100644
index b9532a12e..000000000
--- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt
+++ /dev/null
@@ -1,475 +0,0 @@
-package fr.free.nrw.commons.contributions
-
-import android.Manifest.permission
-import android.app.Activity
-import android.content.Context
-import android.content.Intent
-import android.widget.Toast
-import androidx.activity.result.ActivityResult
-import androidx.activity.result.ActivityResultLauncher
-import androidx.lifecycle.LiveData
-import androidx.paging.LivePagedListBuilder
-import androidx.paging.PagedList
-import fr.free.nrw.commons.R
-import fr.free.nrw.commons.filepicker.DefaultCallback
-import fr.free.nrw.commons.filepicker.FilePicker
-import fr.free.nrw.commons.filepicker.FilePicker.HandleActivityResult
-import fr.free.nrw.commons.filepicker.FilePicker.configuration
-import fr.free.nrw.commons.filepicker.FilePicker.handleExternalImagesPicked
-import fr.free.nrw.commons.filepicker.FilePicker.onPictureReturnedFromDocuments
-import fr.free.nrw.commons.filepicker.FilePicker.openCameraForImage
-import fr.free.nrw.commons.filepicker.FilePicker.openCustomSelector
-import fr.free.nrw.commons.filepicker.FilePicker.openGallery
-import fr.free.nrw.commons.filepicker.UploadableFile
-import fr.free.nrw.commons.kvstore.JsonKvStore
-import fr.free.nrw.commons.location.LatLng
-import fr.free.nrw.commons.location.LocationPermissionsHelper
-import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback
-import fr.free.nrw.commons.location.LocationServiceManager
-import fr.free.nrw.commons.nearby.Place
-import fr.free.nrw.commons.upload.UploadActivity
-import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
-import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE
-import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction
-import fr.free.nrw.commons.utils.ViewUtil.showLongToast
-import fr.free.nrw.commons.utils.ViewUtil.showShortToast
-import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
-import java.util.Arrays
-import javax.inject.Inject
-import javax.inject.Named
-import javax.inject.Singleton
-
-@Singleton
-class ContributionController @Inject constructor(@param:Named("default_preferences") private val defaultKvStore: JsonKvStore) {
- private var locationBeforeImageCapture: LatLng? = null
- private var isInAppCameraUpload = false
- @JvmField
- var locationPermissionCallback: LocationPermissionCallback? = null
- private var locationPermissionsHelper: LocationPermissionsHelper? = null
-
- // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
- // LiveData> failedAndPendingContributionList;
- @JvmField
- var pendingContributionList: LiveData>? = null
- @JvmField
- var failedContributionList: LiveData>? = null
-
- @JvmField
- @Inject
- var locationManager: LocationServiceManager? = null
-
- @JvmField
- @Inject
- var repository: ContributionsRepository? = null
-
- /**
- * Check for permissions and initiate camera click
- */
- fun initiateCameraPick(
- activity: Activity,
- inAppCameraLocationPermissionLauncher: ActivityResultLauncher>,
- resultLauncher: ActivityResultLauncher
- ) {
- val useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true)
- if (!useExtStorage) {
- initiateCameraUpload(activity, resultLauncher)
- return
- }
-
- checkPermissionsAndPerformAction(
- activity,
- {
- if (defaultKvStore.getBoolean("inAppCameraFirstRun")) {
- defaultKvStore.putBoolean("inAppCameraFirstRun", false)
- askUserToAllowLocationAccess(
- activity,
- inAppCameraLocationPermissionLauncher,
- resultLauncher
- )
- } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) {
- createDialogsAndHandleLocationPermissions(
- activity,
- inAppCameraLocationPermissionLauncher, resultLauncher
- )
- } else {
- initiateCameraUpload(activity, resultLauncher)
- }
- },
- R.string.storage_permission_title,
- R.string.write_storage_permission_rationale,
- *PERMISSIONS_STORAGE
- )
- }
-
- /**
- * Asks users to provide location access
- *
- * @param activity
- */
- private fun createDialogsAndHandleLocationPermissions(
- activity: Activity,
- inAppCameraLocationPermissionLauncher: ActivityResultLauncher>?,
- resultLauncher: ActivityResultLauncher
- ) {
- locationPermissionCallback = object : LocationPermissionCallback {
- override fun onLocationPermissionDenied(toastMessage: String) {
- Toast.makeText(
- activity,
- toastMessage,
- Toast.LENGTH_LONG
- ).show()
- initiateCameraUpload(activity, resultLauncher)
- }
-
- override fun onLocationPermissionGranted() {
- if (!locationPermissionsHelper!!.isLocationAccessToAppsTurnedOn()) {
- showLocationOffDialog(
- activity, R.string.in_app_camera_needs_location,
- R.string.in_app_camera_location_unavailable, resultLauncher
- )
- } else {
- initiateCameraUpload(activity, resultLauncher)
- }
- }
- }
-
- locationPermissionsHelper = LocationPermissionsHelper(
- activity, locationManager!!, locationPermissionCallback
- )
- inAppCameraLocationPermissionLauncher?.launch(
- arrayOf(permission.ACCESS_FINE_LOCATION)
- )
- }
-
- /**
- * Shows a dialog alerting the user about location services being off and asking them to turn it
- * on
- * TODO: Add a seperate callback in LocationPermissionsHelper for this.
- * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
- *
- * @param activity Activity reference
- * @param dialogTextResource Resource id of text to be shown in dialog
- * @param toastTextResource Resource id of text to be shown in toast
- * @param resultLauncher
- */
- private fun showLocationOffDialog(
- activity: Activity, dialogTextResource: Int,
- toastTextResource: Int, resultLauncher: ActivityResultLauncher