diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 36c57f9e9..7a1e7c030 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -1,6 +1,6 @@
name: Android CI
-on: [push, pull_request]
+on: [push, pull_request, workflow_dispatch]
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
@@ -10,22 +10,19 @@ jobs:
build:
name: Run tests and generate APK
runs-on: ubuntu-latest
- strategy:
- matrix:
- api-level: [23]
-
steps:
- - uses: actions/checkout@v2.4.0
+ - name: Checkout code
+ uses: actions/checkout@v3
- name: Set up JDK
- uses: actions/setup-java@v2.5.0
+ uses: actions/setup-java@v3
with:
- distribution: "temurin"
- java-version: 8
+ distribution: 'temurin'
+ java-version: '17'
- name: Cache packages
id: cache-packages
- uses: actions/cache@v2.1.7
+ uses: actions/cache@v3
with:
path: |
~/.gradle/caches
@@ -33,49 +30,66 @@ jobs:
key: gradle-packages-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }}
restore-keys: gradle-packages-${{ runner.os }}
+ - name: Access test login credentials
+ run: |
+ echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties
+ echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties
+
- name: AVD cache
- uses: actions/cache@v2
+ if: github.event_name != 'pull_request'
+ uses: actions/cache@v3
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
- key: avd-${{ matrix.api-level }}
+ key: avd-tablet-api-24
- name: Create AVD and generate snapshot for caching
- if: steps.avd-cache.outputs.cache-hit != 'true'
+ if: steps.avd-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request'
uses: reactivecircus/android-emulator-runner@v2
with:
- api-level: ${{ matrix.api-level }}
+ api-level: 24
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- disable-animations: false
+ disable-animations: true
script: echo "Generated AVD snapshot for caching."
- name: Run Instrumentation tests
+ if: github.event_name != 'pull_request'
uses: reactivecircus/android-emulator-runner@v2
with:
- api-level: ${{ matrix.api-level }}
+ api-level: 24
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
- profile: Nexus 6
- script: ./gradlew connectedBetaDebugAndroidTest --stacktrace
+ profile: Nexus 10
+ script: |
+ adb shell content insert --uri content://settings/system --bind name:s:accelerometer_rotation --bind value:i:0
+ adb shell content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:0
+ adb emu geo fix 37.422131 -122.084801
+ ./gradlew connectedBetaDebugAndroidTest --stacktrace
- - name: Run Unit tests
+ - name: Run Unit tests with unified coverage
+ if: github.event_name != 'pull_request'
+ run: ./gradlew -Pcoverage testBetaDebugUnitTestUnifiedCoverage --stacktrace
+
+ - name: Run Unit tests without unified coverage
+ if: github.event_name == 'pull_request'
run: ./gradlew -Pcoverage testBetaDebugUnitTestCoverage --stacktrace
- name: Upload Test Report to Codecov
+ if: github.event_name != 'pull_request'
run: |
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
- ./codecov -f "app/build/reports/jacoco/testBetaDebugUnitTestCoverage/testBetaDebugUnitTestCoverage.xml" -Z
+ ./codecov -f "app/build/reports/jacoco/testBetaDebugUnitTestUnifiedCoverage/testBetaDebugUnitTestUnifiedCoverage.xml" -Z
- name: Generate betaDebug APK
run: bash ./gradlew assembleBetaDebug --stacktrace
- name: Upload betaDebug APK
- uses: actions/upload-artifact@v2.3.1
+ uses: actions/upload-artifact@v3
with:
name: betaDebugAPK
path: app/build/outputs/apk/beta/debug/app-*.apk
@@ -84,7 +98,7 @@ jobs:
run: bash ./gradlew assembleProdDebug --stacktrace
- name: Upload prodDebug APK
- uses: actions/upload-artifact@v2.3.1
+ uses: actions/upload-artifact@v3
with:
name: prodDebugAPK
path: app/build/outputs/apk/prod/debug/app-*.apk
diff --git a/.gitignore b/.gitignore
index 418e4c380..e54ea2551 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,7 @@ app/src/main/jniLibs
#https://docs.opencv.org/3.3.0/
/libraries/opencv/javadoc/
captures/*
+
+# Test and other output
+app/jacoco.exec
+app/CommonsContributions
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index f277755ba..5c297a65e 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -39,21 +39,18 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
@@ -318,9 +315,7 @@
-
-
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index a5d456928..f39734eb4 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,16 +1,12 @@
-
-
-
-
@@ -25,13 +21,11 @@
-
-
@@ -47,6 +41,5 @@
-
\ No newline at end of file
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 000000000..b140127f9
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,5 @@
+# See: https://git-scm.com/docs/git-shortlog#_mapping_authors
+#
+Brooke Vibber
+Brooke Vibber
+Brooke Vibber
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 405b7d9de..e7accf82b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,257 @@
# Wikimedia Commons for Android
+## 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
diff --git a/CREDITS b/CREDITS
index 770dbdde4..3fe6b00d0 100644
--- a/CREDITS
+++ b/CREDITS
@@ -53,7 +53,6 @@ their contribution to the product.
* Butterknife
* GSON
* Timber
-* MapBox
3rd party open source apps from which significant code has been reused:
* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia
diff --git a/README.md b/README.md
index 0c26f8e1e..cefb267aa 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
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! :-)
@@ -15,7 +15,7 @@ Initially started by the Wikimedia Foundation, this app is now maintained by gra
## Documentation
-We try to have an extensive documentation at our [documentation repository][4]:
+Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
* [User Documentation][5]
* [Contributor Documentation][6]
@@ -29,11 +29,11 @@ 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) |
| :---: | :---: | :---: | :---: | :---: |
-| [
domdomegg](https://github.com/domdomegg) | [
maskaravivek](https://github.com/maskaravivek) | [
psh](https://github.com/psh) | [
brion](https://github.com/brion) | [
ashishkumar468](https://github.com/ashishkumar468) |
-| [
whym](https://github.com/whym) | [
akaita](https://github.com/akaita) | [
madhurgupta10](https://github.com/madhurgupta10) | [
veyndan](https://github.com/veyndan) | [
ujjwalagrawal17](https://github.com/ujjwalagrawal17) |
-| [
macgills](https://github.com/macgills) | [
dbrant](https://github.com/dbrant) | [
vanshikaarora](https://github.com/vanshikaarora) | [
sandarumk](https://github.com/sandarumk) | [
tanvidadu](https://github.com/tanvidadu) |
-| [
cypherop](https://github.com/cypherop) | [
tobias47n9e](https://github.com/tobias47n9e) | [
hismaeel](https://github.com/hismaeel) | [
tshradheya](https://github.com/tshradheya) | [
addshore](https://github.com/addshore) |
-| [
knight-shade](https://github.com/knight-shade) | [
siebrand](https://github.com/siebrand) | [
sivaraam](https://github.com/sivaraam) | [
Bluesir9](https://github.com/Bluesir9) | [
kbhardwaj123](https://github.com/kbhardwaj123) |
+| [
domdomegg](https://github.com/domdomegg) | [
maskaravivek](https://github.com/maskaravivek) | [
psh](https://github.com/psh) | [
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) | [
veyndan](https://github.com/veyndan) | [
ujjwalagrawal17](https://github.com/ujjwalagrawal17) |
+| [
macgills](https://github.com/macgills) | [
dbrant](https://github.com/dbrant) | [
vanshikaarora](https://github.com/vanshikaarora) | [
sivaraam](https://github.com/sivaraam) | [
Ayan-10](https://github.com/Ayan-10) |
+| [
shashankiitbhu](https://github.com/shashankiitbhu) | [
Pratham2305](https://github.com/Pratham2305) | [
sandarumk](https://github.com/sandarumk) | [
tanvidadu](https://github.com/tanvidadu) | [
cypherop](https://github.com/cypherop) |
+| [
Prince-kushwaha](https://github.com/Prince-kushwaha) | [
tobias47n9e](https://github.com/tobias47n9e) | [
4D17Y4](https://github.com/4D17Y4) | [
hismaeel](https://github.com/hismaeel) | [
tshradheya](https://github.com/tshradheya) |
.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).
diff --git a/app/build.gradle b/app/build.gradle
index dd31600b7..1cc594e7b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -5,7 +5,7 @@ apply from: '../gitutils.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
-apply plugin: 'kotlin-android-extensions'
+apply plugin: 'kotlin-parcelize'
apply from: "$rootDir/jacoco.gradle"
def isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file('../play.p12').exists()
@@ -16,13 +16,17 @@ if (isRunningOnTravisAndIsNotPRBuild) {
dependencies {
- implementation project(':wikimedia-data-client')
// Utils
implementation 'in.yuvi:http.fluent:1.3'
implementation 'com.google.code.gson:gson:2.8.5'
- implementation ("com.squareup.okhttp3:okhttp:$OKHTTP_VERSION"){
- force = true //API 19 support
+ implementation ("com.squareup.okhttp3:okhttp:$OKHTTP_VERSION!!"){
+ // Forcing dependency versions using force = true on a first-level dependency has been deprecated.
+ // Ref: https://docs.gradle.org/7.5/userguide/upgrading_version_5.html#forced_dependencies
+ //force = true //API 19 support
}
+ implementation 'com.squareup.retrofit2:retrofit:2.8.1'
+ implementation "com.squareup.retrofit2:converter-gson:2.8.1"
+ implementation "com.squareup.retrofit2:adapter-rxjava2:2.8.1"
implementation 'com.squareup.okio:okio:2.2.2'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.3'
@@ -38,17 +42,33 @@ dependencies {
implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
implementation 'com.github.chrisbanes:PhotoView:2.0.0'
implementation 'com.github.pedrovgs:renderers:3.3.3'
- implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.1.0'
- implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-localization-v8:0.11.0'
- implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-scalebar-v9:0.4.0'
- implementation 'com.mapbox.mapboxsdk:mapbox-android-telemetry:6.1.0'
- implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
- implementation 'com.dinuscxj:circleprogressbar:1.1.1'
- implementation 'com.karumi:dexter:5.0.0'
- implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
- implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
+ implementation "org.maplibre.gl:android-sdk:$MAPLIBRE_VERSION"
+ implementation 'org.maplibre.gl:android-plugin-scalebar-v9:1.0.0'
+
+ implementation 'com.jakewharton.timber:timber:4.7.1'
+ implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
+ implementation "com.google.android.material:material:1.12.0"
+ implementation 'com.karumi:dexter:5.0.0'
+ implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+
+ // Jetpack Compose
+ def composeBom = platform('androidx.compose:compose-bom:2024.11.00')
+
+ implementation "androidx.activity:activity-compose:1.9.3"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
+ implementation (composeBom)
+ implementation "androidx.compose.runtime:runtime"
+ implementation "androidx.compose.ui:ui"
+ implementation "androidx.compose.ui:ui-viewbinding"
+ implementation "androidx.compose.ui:ui-graphics"
+ implementation "androidx.compose.ui:ui-tooling"
+ implementation "androidx.compose.foundation:foundation"
+ implementation "androidx.compose.foundation:foundation-layout"
+ implementation "androidx.compose.material3:material3"
+ androidTestImplementation(composeBom)
- kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION"
implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION"
@@ -69,56 +89,63 @@ dependencies {
// Dependency injector
implementation "com.google.dagger:dagger-android:$DAGGER_VERSION"
implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION"
//Mocking
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
- testImplementation 'org.mockito:mockito-inline:2.13.0'
- testImplementation 'org.mockito:mockito-core:2.25.1'
- testImplementation "org.powermock:powermock-module-junit4:2.0.2"
- testImplementation "org.powermock:powermock-api-mockito2:2.0.2"
+ testImplementation 'org.mockito:mockito-inline:5.2.0'
+ testImplementation 'org.mockito:mockito-core:5.6.0'
+ testImplementation "org.powermock:powermock-module-junit4:2.0.9"
+ testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
+ testImplementation("io.mockk:mockk:1.13.5")
// Unit testing
testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.robolectric:robolectric:4.6-alpha-1'
- testImplementation 'androidx.test:core:1.4.0'
+ testImplementation 'org.robolectric:robolectric:4.11.1'
+ testImplementation 'androidx.test:core:1.5.0'
+ testImplementation "androidx.test:runner:1.5.2"
+ testImplementation 'androidx.test.ext:junit:1.1.5'
+ testImplementation "androidx.test:rules:1.5.0"
testImplementation "com.squareup.okhttp3:mockwebserver:$OKHTTP_VERSION"
- testImplementation "com.jraska.livedata:testing-ktx:1.1.2"
- testImplementation "androidx.arch.core:core-testing:2.1.0"
- testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.0"
- testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.0"
- testImplementation 'com.facebook.soloader:soloader:0.10.1'
- testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0"
+ testImplementation "com.jraska.livedata:testing-ktx:1.2.0"
+ testImplementation "androidx.arch.core:core-testing:2.2.0"
+ testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0"
+ testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0"
+ testImplementation 'com.facebook.soloader:soloader:0.10.5'
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
+ debugImplementation("androidx.fragment:fragment-testing:1.6.2")
+ testImplementation "commons-io:commons-io:2.6"
// Android testing
- androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
- androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
- androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
- androidTestImplementation 'androidx.test:runner:1.2.0'
- androidTestImplementation 'androidx.test:rules:1.2.0'
- androidTestImplementation 'androidx.annotation:annotation:1.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha04'
+ androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0-alpha04'
+ androidTestImplementation 'androidx.test:runner:1.4.0'
+ androidTestImplementation 'androidx.test:rules:1.4.1-alpha04'
+ androidTestImplementation 'androidx.test:core:1.4.0'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.annotation:annotation:1.3.0'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.8.0'
- androidTestUtil 'androidx.test:orchestrator:1.2.0'
+ androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
+ androidTestUtil 'androidx.test:orchestrator:1.4.1'
// Debugging
debugImplementation "com.squareup.leakcanary:leakcanary-android:$LEAK_CANARY_VERSION"
- releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
- testImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
// Support libraries
implementation "com.google.android.material:material:1.1.0-alpha04"
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
- implementation "androidx.exifinterface:exifinterface:1.3.2"
+ implementation 'androidx.exifinterface:exifinterface:1.3.7'
implementation "androidx.core:core-ktx:$CORE_KTX_VERSION"
- implementation "androidx.multidex:multidex:2.0.1"
+ implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
//swipe_layout
implementation 'com.daimajia.swipelayout:library:1.2.0@aar'
@@ -129,7 +156,6 @@ dependencies {
implementation "androidx.room:room-rxjava2:$ROOM_VERSION"
kapt "androidx.room:room-compiler:$ROOM_VERSION"
// For Kotlin use kapt instead of annotationProcessor
- implementation 'com.squareup.retrofit2:retrofit:2.8.1'
testImplementation "androidx.arch.core:core-testing:2.1.0"
// Pref
@@ -137,33 +163,61 @@ dependencies {
implementation "androidx.preference:preference:$PREFERENCE_VERSION"
// Kotlin
implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION"
+ //Android Media
+ implementation 'com.github.juanitobananas:AndroidMediaUtil:v1.0-1'
implementation "androidx.multidex:multidex:$MULTIDEX_VERSION"
- def work_version = "2.6.0"
+ def work_version = "2.8.1"
// Kotlin + coroutines
implementation "androidx.work:work-runtime-ktx:$work_version"
+ implementation("androidx.work:work-runtime:$work_version")
testImplementation "androidx.work:work-testing:$work_version"
//Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
+ kaptTest "androidx.databinding:databinding-compiler:8.0.2"
+ kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2"
- implementation("io.github.coordinates2country:coordinates2country-android:1.2") { exclude group: 'com.google.android', module: 'android' }
+ implementation("io.github.coordinates2country:coordinates2country-android:1.8") { exclude group: 'com.google.android', module: 'android' }
+
+ //OSMDroid
+ implementation ("org.osmdroid:osmdroid-android:$OSMDROID_VERSION")
+ constraints {
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") {
+ because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
+ }
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") {
+ because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
+ }
+ }
+}
+
+task disableAnimations(type: Exec) {
+ def adb = "$System.env.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 {
+ connectedBetaDebugAndroidTest.dependsOn disableAnimations
+ connectedProdDebugAndroidTest.dependsOn disableAnimations
}
android {
- compileSdkVersion 30
+ compileSdkVersion 34
defaultConfig {
//applicationId 'fr.free.nrw.commons'
- versionCode 1025
- versionName '3.1.1'
+ versionCode 1043
+ versionName '5.1.2'
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
- minSdkVersion 19
- targetSdkVersion 30
+ minSdkVersion 21
+ targetSdkVersion 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
@@ -173,17 +227,23 @@ android {
vectorDrawables.useSupportLibrary = true
}
-
packagingOptions {
- exclude 'META-INF/androidx.*'
- exclude 'META-INF/proguard/androidx-annotations.pro'
+ jniLibs {
+ excludes += ['META-INF/androidx.*']
+ }
+ resources {
+ excludes += ['META-INF/androidx.*', 'META-INF/proguard/androidx-annotations.pro', '/META-INF/LICENSE.md', '/META-INF/LICENSE-notice.md']
+ }
}
+
testOptions {
animationsDisabled true
- unitTests.returnDefaultValues = true
- unitTests.includeAndroidResources = true
+ unitTests {
+ returnDefaultValues = true
+ includeAndroidResources = true
+ }
unitTests.all {
jvmArgs '-noverify'
@@ -208,16 +268,18 @@ android {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt'
+ signingConfig signingConfigs.debug
if (isRunningOnTravisAndIsNotPRBuild) {
signingConfig signingConfigs.release
}
}
debug {
minifyEnabled false
- testCoverageEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt'
versionNameSuffix "-debug-" + getBranchName()
+ enableUnitTestCoverage true
+ enableAndroidTestCoverage true
}
}
@@ -231,6 +293,8 @@ android {
configurations.all {
resolutionStrategy.force 'androidx.annotation:annotation:1.1.0'
+ resolutionStrategy.force 'com.jakewharton.timber:timber:4.7.1'
+ resolutionStrategy.force 'androidx.fragment:fragment:1.3.6'
exclude module: 'okhttp-ws'
}
flavorDimensions 'tier'
@@ -254,19 +318,20 @@ android {
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", "\"" + System.getenv("test_user_name") + "\""
- buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
+ buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
+ buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\""
-
dimension 'tier'
}
@@ -289,40 +354,61 @@ android {
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", "\"" + System.getenv("test_user_name") + "\""
- buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
+ buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
+ buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\""
-
dimension 'tier'
}
}
- lintOptions {
- disable 'MissingTranslation'
- disable 'ExtraTranslation'
- abortOnError false
- }
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
}
buildToolsVersion buildToolsVersion
buildFeatures {
viewBinding true
+ compose true
}
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.5.8'
+ }
+ namespace 'fr.free.nrw.commons'
+ lint {
+ abortOnError false
+ disable 'MissingTranslation', 'ExtraTranslation'
+ }
+}
+String getTestUserName() {
+ def propFile = rootProject.file("./local.properties")
+ def properties = new Properties()
+ properties.load(new FileInputStream(propFile))
+ return properties['TEST_USER_NAME']
+}
+
+String getTestPassword() {
+ def propFile = rootProject.file("./local.properties")
+ def properties = new Properties()
+ properties.load(new FileInputStream(propFile))
+ return properties['TEST_USER_PASSWORD']
}
if (isRunningOnTravisAndIsNotPRBuild) {
@@ -338,7 +424,3 @@ if (isRunningOnTravisAndIsNotPRBuild) {
}
}
}
-
-androidExtensions {
- experimental = true
-}
diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt
index c71f80777..63981633b 100644
--- a/app/proguard-rules.txt
+++ b/app/proguard-rules.txt
@@ -31,6 +31,17 @@
-keepattributes Signature
# Retain declared checked exceptions for use by a Proxy instance.
-keepattributes Exceptions
+
+# Note: The model package right now seems to include some other classes that
+# are not used for serialization / deserialization over Gson. Hopefully
+# that's not a problem since it only prevents R8 from avoiding trimming
+# of few more classes.
+-keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.actions.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.auth.csrf.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.auth.login.** { *; }
+-keepclasseswithmembers class fr.free.nrw.commons.wikidata.mwapi.** { *; }
+
# --- /Retrofit ---
# --- OkHttp + Okio ---
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
index 56e30f2d1..50dfe8e7f 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt
@@ -3,7 +3,6 @@ package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
-import androidx.test.InstrumentationRegistry
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
@@ -12,12 +11,14 @@ import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import 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.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -27,93 +28,122 @@ class AboutActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
+ private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
@Before
fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
Intents.init()
- Intents.intending(CoreMatchers.not(IntentMatchers.isInternal()))
- .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+ Intents
+ .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+ .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+ }
+
+ @After
+ fun cleanUp() {
+ Intents.release()
}
@Test
- @Ignore("Fix Failing Test")
fun testBuildNumber() {
- Espresso.onView(ViewMatchers.withId(R.id.about_version))
- .check(ViewAssertions.matches(
- withText(getApplicationContext().getVersionNameWithSha())
- ))
+ Espresso
+ .onView(ViewMatchers.withId(R.id.about_version))
+ .check(
+ ViewAssertions.matches(
+ withText(getApplicationContext().getVersionNameWithSha()),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
fun testLaunchWebsite() {
Espresso.onView(ViewMatchers.withId(R.id.website_launch_icon)).perform(ViewActions.click())
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
- IntentMatchers.hasData(Urls.WEBSITE_URL)))
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.WEBSITE_URL),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
fun testLaunchFacebook() {
Espresso.onView(ViewMatchers.withId(R.id.facebook_launch_icon)).perform(ViewActions.click())
- Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
- Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
- IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME)))
+ Intents.intended(
+ CoreMatchers.anyOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
+ IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing 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)))
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.GITHUB_REPO_URL),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
- fun testLaunchRateUs() {
- val appPackageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
- Espresso.onView(ViewMatchers.withId(R.id.about_rate_us)).perform(ViewActions.click())
- Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
- Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName"),
- IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName")))
- }
-
- @Test
- @Ignore("Fix Failing Test")
fun testLaunchAboutPrivacyPolicy() {
Espresso.onView(ViewMatchers.withId(R.id.about_privacy_policy)).perform(ViewActions.click())
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
- IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL)))
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
fun testLaunchTranslate() {
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
- val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0]
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
- IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode")))
+ val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
fun testLaunchAboutCredits() {
Espresso.onView(ViewMatchers.withId(R.id.about_credits)).perform(ViewActions.click())
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
- IntentMatchers.hasData(Urls.CREDITS_URL)))
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.CREDITS_URL),
+ ),
+ )
+ }
+
+ @Test
+ fun testLaunchUserGuide() {
+ Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click())
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.USER_GUIDE_URL),
+ ),
+ )
}
@Test
- @Ignore("Fix Failing Test")
fun testLaunchAboutFaq() {
Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click())
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
- IntentMatchers.hasData(Urls.FAQ_URL)))
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(Urls.FAQ_URL),
+ ),
+ )
}
-
- @Test
- fun orientationChange() {
- UITestHelper.changeOrientation(activityRule)
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt
deleted file mode 100644
index c8dacff49..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/AchievementsActivityTest.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.contrib.DrawerActions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
-import androidx.test.espresso.intent.rule.IntentsTestRule
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.profile.ProfileActivity
-import org.junit.Before
-import org.junit.Ignore
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class AchievementsActivityTest {
- @get:Rule
- var activityRule = IntentsTestRule(LoginActivity::class.java)
-
- @Before
- fun setup() {
- UITestHelper.skipWelcome()
- UITestHelper.loginUser()
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun testAchievements() {
- onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
-
- Intents.intended(hasComponent(ProfileActivity::class.java.name))
- }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt
deleted file mode 100644
index 9ed76f42c..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.runner.AndroidJUnit4
-import org.junit.Rule
-import org.junit.runner.RunWith
-import android.net.Uri
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.action.ViewActions
-import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.rule.ActivityTestRule
-import fr.free.nrw.commons.upload.UploadActivity
-import org.hamcrest.Matchers
-import org.hamcrest.core.AllOf
-import org.junit.Ignore
-import org.junit.Test
-
-@RunWith(AndroidJUnit4::class)
-class DepictionSearchTest {
- @get:Rule
- var activityRule = ActivityTestRule(UploadActivity::class.java)
-
- @Test
- @Ignore("Fix Failing Test")
- fun TestForCaptionsAndDepictions() {
- val imageUri = Uri.parse("file://mnt/sdcard/image.jpg")
-
- // Build a result to return from the Camera app
-
-
- // 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
-
- Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text))
- .perform(ViewActions.typeText("caption in english"))
- Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text))
- .perform(ViewActions.typeText("description in english"))
- Espresso.onData(AllOf.allOf(Matchers.anything("spinner text"))).atPosition(1).perform(ViewActions.click());
- Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text))
- .perform(ViewActions.typeText("caption in some other language"))
- Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text))
- .perform(ViewActions.typeText("description in some other language"))
- Espresso.onView(ViewMatchers.withId(R.id.btn_next))
- .perform(ViewActions.click())
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LeaderboardActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LeaderboardActivityTest.kt
deleted file mode 100644
index 20e387927..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/LeaderboardActivityTest.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-package fr.free.nrw.commons
-
-import android.app.Activity
-import android.app.Instrumentation.ActivityResult
-import android.view.View
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.PerformException
-import androidx.test.espresso.UiController
-import androidx.test.espresso.ViewAction
-import androidx.test.espresso.action.ViewActions
-import androidx.test.espresso.contrib.DrawerActions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.intent.Intents.intending
-import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal
-import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.auth.LoginActivity
-import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
-import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
-import com.google.android.material.tabs.TabLayout
-import org.hamcrest.CoreMatchers.allOf
-import org.hamcrest.CoreMatchers.not
-import org.junit.Before
-import org.junit.Ignore
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class LeaderboardActivityTest {
- @get:Rule
- var activityRule = ActivityTestRule(LoginActivity::class.java)
-
- @Before
- fun setup() {
- try {
- Intents.init()
- } catch (ex: IllegalStateException) {
-
- }
- UITestHelper.skipWelcome()
- intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null))
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun testScrollToRankFromAbove() {
- Espresso.onView(ViewMatchers.withId(R.id.drawer_layout)).perform(DrawerActions.open())
-
- Espresso.onView(ViewMatchers.withId(R.id.tab_layout)).perform(ViewActions.click())
- Espresso.onView(ViewMatchers.withId(R.id.tab_layout)).perform(selectTabAtPosition(1))
-
- UITestHelper.sleep(10000)
-
- Espresso.onView(ViewMatchers.withId(R.id.scroll)).perform(ViewActions.click())
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun testScrollToRankFromBelow() {
- Espresso.onView(ViewMatchers.withId(R.id.drawer_layout)).perform(DrawerActions.open())
-
- Espresso.onView(ViewMatchers.withId(R.id.tab_layout)).perform(ViewActions.click())
- Espresso.onView(ViewMatchers.withId(R.id.tab_layout)).perform(selectTabAtPosition(1))
-
- UITestHelper.sleep(10000)
-
- Espresso.onView(ViewMatchers.withId(R.id.leaderboard_list)).perform(ViewActions.swipeUp())
- Espresso.onView(ViewMatchers.withId(R.id.leaderboard_list)).perform(ViewActions.swipeUp())
-
- Espresso.onView(ViewMatchers.withId(R.id.scroll)).perform(ViewActions.click())
- }
-
- private fun selectTabAtPosition(tabIndex: Int): ViewAction {
- return object : ViewAction {
- override fun getDescription() = "with tab at index $tabIndex"
-
- override fun getConstraints() = allOf(isDisplayed(), isAssignableFrom(TabLayout::class.java))
-
- override fun perform(uiController: UiController, view: View) {
- val tabLayout = view as TabLayout
- val tabAtIndex: TabLayout.Tab = tabLayout.getTabAt(tabIndex)
- ?: throw PerformException.Builder()
- .withCause(Throwable("No tab at index $tabIndex"))
- .build()
-
- tabAtIndex.select()
- }
- }
- }
-}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
index 43adfcdf9..9bfc9321b 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt
@@ -8,17 +8,18 @@ import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal
import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.contributions.MainActivity
+import fr.free.nrw.commons.auth.SignupActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.not
+import org.junit.After
import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -28,36 +29,41 @@ class LoginActivityTest {
@get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java)
+ private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
@Before
fun setup() {
- try {
- Intents.init()
- } catch (ex: IllegalStateException) {
-
- }
+ device.setOrientationNatural()
+ device.freezeRotation()
+ Intents.init()
UITestHelper.skipWelcome()
intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null))
}
- @Test
- @Ignore("Fix Failing Test")
- fun testLogin() {
- UITestHelper.loginUser()
- Intents.intended(hasComponent(MainActivity::class.java.name))
+ @After
+ fun cleanUp() {
+ Intents.release()
}
@Test
- @Ignore("Fix Failing Test")
fun testForgotPassword() {
- UITestHelper.sleep(3000)
- Espresso.onView(ViewMatchers.withId(R.id.forgot_password))
- .perform(ViewActions.click())
- Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL)));
+ Espresso.onView(ViewMatchers.withId(R.id.forgot_password)).perform(ViewActions.click())
+ Intents.intended(
+ CoreMatchers.allOf(
+ IntentMatchers.hasAction(Intent.ACTION_VIEW),
+ IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL),
+ ),
+ )
+ }
+
+ @Test
+ fun testSignupButton() {
+ Espresso.onView(ViewMatchers.withId(R.id.sign_up_button)).perform(ViewActions.click())
+ Intents.intended(IntentMatchers.hasComponent(SignupActivity::class.java.name))
}
@Test
- @Ignore("Fix Failing Test")
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
index 3beb73948..3d2fc9e48 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt
@@ -1,21 +1,214 @@
package fr.free.nrw.commons
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.contributions.MainActivity
-import org.junit.Ignore
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import com.google.gson.Gson
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import fr.free.nrw.commons.notification.NotificationActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.After
+import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+@LargeTest
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
- var activityRule = ActivityTestRule(MainActivity::class.java)
+ var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
+
+ @get:Rule
+ var mGrantPermissionRule: GrantPermissionRule =
+ GrantPermissionRule.grant(
+ "android.permission.ACCESS_FINE_LOCATION",
+ )
+
+ private val device: UiDevice =
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ private lateinit var defaultKvStore: JsonKvStore
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
+ UITestHelper.loginUser()
+ UITestHelper.skipWelcome()
+ Intents.init()
+ Intents
+ .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+ .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val storeName = context.packageName + "_preferences"
+ defaultKvStore = JsonKvStore(context, storeName, Gson())
+ }
+
+ @After
+ fun cleanUp() {
+ Intents.release()
+ }
@Test
- @Ignore("Fix Failing Test")
- fun orientationChange() {
- UITestHelper.changeOrientation(activityRule)
+ fun testNearby() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 1,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ Espresso
+ .onView(ViewMatchers.withId(R.id.fragmentContainer))
+ .check(matches(ViewMatchers.isDisplayed()))
+ UITestHelper.sleep(10000)
+ val actionMenuItemView2 =
+ Espresso.onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.list_sheet),
+ ViewMatchers.withContentDescription("List"),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.toolbar),
+ 1,
+ ),
+ 0,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ )
+ actionMenuItemView2.perform(ViewActions.click())
+ UITestHelper.sleep(1000)
}
-}
\ No newline at end of file
+
+ @Test
+ fun testExplore() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 2,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ Espresso
+ .onView(ViewMatchers.withId(R.id.fragmentContainer))
+ .check(matches(ViewMatchers.isDisplayed()))
+ UITestHelper.sleep(1000)
+ }
+
+ @Test
+ fun testContributions() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 0,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ Espresso
+ .onView(ViewMatchers.withId(R.id.fragmentContainer))
+ .check(matches(ViewMatchers.isDisplayed()))
+ Espresso
+ .onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.contributionImage),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.contributionsList),
+ 0,
+ ),
+ 1,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ val actionMenuItemView =
+ Espresso.onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.menu_bookmark_current_image),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.toolbar),
+ 1,
+ ),
+ 0,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ )
+ actionMenuItemView.perform(ViewActions.click())
+ UITestHelper.sleep(3000)
+ }
+
+ @Test
+ fun testBookmarks() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 3,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ UITestHelper.sleep(1000)
+ }
+
+ @Test
+ fun testNotifications() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.notifications),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.toolbar),
+ 1,
+ ),
+ 1,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name))
+ Espresso.pressBack()
+ UITestHelper.sleep(1000)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt
new file mode 100644
index 000000000..003fc0674
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt
@@ -0,0 +1,67 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
+import androidx.test.espresso.intent.rule.IntentsTestRule
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.profile.ProfileActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ProfileActivityTest {
+ @get:Rule
+ var activityRule = IntentsTestRule(LoginActivity::class.java)
+
+ private val device: UiDevice = UiDevice.getInstance(getInstrumentation())
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
+ UITestHelper.loginUser()
+ UITestHelper.skipWelcome()
+ Intents
+ .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+ .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+ }
+
+ @Test
+ fun testProfile() {
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withContentDescription("More"),
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 4,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ onView(Matchers.allOf(withId(R.id.more_profile))).perform(
+ ViewActions.scrollTo(),
+ ViewActions.click(),
+ )
+ device.swipe(1033, 1346, 531, 1346, 20)
+ UITestHelper.sleep(5000)
+ Intents.intended(hasComponent(ProfileActivity::class.java.name))
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
new file mode 100644
index 000000000..3f6487e47
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt
@@ -0,0 +1,19 @@
+package fr.free.nrw.commons
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import fr.free.nrw.commons.review.ReviewActivity
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ReviewActivityTest {
+ @get:Rule
+ var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java)
+
+ @Test
+ fun orientationChange() {
+ UITestHelper.changeOrientation(activityRule)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
index 5e58fca9b..69ce412b9 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt
@@ -1,19 +1,59 @@
package fr.free.nrw.commons
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.explore.SearchActivity
+import org.hamcrest.Matchers
+import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import fr.free.nrw.commons.explore.SearchActivity
@RunWith(AndroidJUnit4::class)
class SearchActivityTest {
@get:Rule
var activityRule = ActivityTestRule(SearchActivity::class.java)
- @Test
- fun orientationChange() {
- UITestHelper.changeOrientation(activityRule)
+ private val device: UiDevice =
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
}
-}
\ No newline at end of file
+
+ @Test
+ fun exploreActivityTest() {
+ val searchAutoComplete =
+ Espresso.onView(
+ Matchers.allOf(
+ UITestHelper.childAtPosition(
+ Matchers.allOf(
+ ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
+ UITestHelper.childAtPosition(
+ ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
+ 1,
+ ),
+ ),
+ 0,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ )
+ searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard())
+ UITestHelper.sleep(5000)
+ device.swipe(1000, 1400, 500, 1400, 20)
+ device.swipe(800, 1400, 600, 1400, 20)
+ device.swipe(800, 1400, 600, 1400, 20)
+ device.swipe(800, 1400, 600, 1400, 20)
+ device.swipe(800, 1400, 600, 1400, 20)
+ device.swipe(800, 1400, 600, 1400, 20)
+ UITestHelper.sleep(1000)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt
new file mode 100644
index 000000000..ec132b447
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt
@@ -0,0 +1,65 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.ActivityTestRule
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.auth.LoginActivity
+import fr.free.nrw.commons.settings.SettingsActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SettingsActivityLoggedInTest {
+ @get:Rule
+ var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
+
+ private val device: UiDevice =
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
+ UITestHelper.loginUser()
+ UITestHelper.skipWelcome()
+ Intents
+ .intending(CoreMatchers.not(IntentMatchers.isInternal()))
+ .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+ }
+
+ @Test
+ fun testSettings() {
+ Espresso
+ .onView(
+ Matchers.allOf(
+ ViewMatchers.withContentDescription("More"),
+ UITestHelper.childAtPosition(
+ UITestHelper.childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 4,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform(
+ ViewActions.scrollTo(),
+ ViewActions.click(),
+ )
+ Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name))
+ UITestHelper.sleep(1000)
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
index 9db5a1ee4..c5a91cd56 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt
@@ -1,29 +1,26 @@
package fr.free.nrw.commons
+import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
-import androidx.test.espresso.matcher.PreferenceMatchers
-import androidx.test.espresso.matcher.ViewMatchers.*
-import androidx.test.filters.LargeTest
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.matcher.ViewMatchers.isEnabled
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
import com.google.gson.Gson
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.kvstore.JsonKvStore
-import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.settings.SettingsActivity
-import org.hamcrest.Matchers.allOf
-import org.hamcrest.core.IsNot.not
-import org.junit.Assert.assertEquals
+import org.hamcrest.CoreMatchers.allOf
import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-@LargeTest
@RunWith(AndroidJUnit4::class)
class SettingsActivityTest {
private lateinit var defaultKvStore: JsonKvStore
@@ -31,130 +28,39 @@ class SettingsActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(SettingsActivity::class.java)
+ private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
@Before
fun setup() {
+ device.setOrientationNatural()
+ device.freezeRotation()
val context = InstrumentationRegistry.getInstrumentation().targetContext
val storeName = context.packageName + "_preferences"
defaultKvStore = JsonKvStore(context, storeName, Gson())
}
@Test
- @Ignore("Fix Failing Test")
- fun setRecentUploadLimitTo123() {
- // Open "Use external storage" preference
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .perform(click())
-
- // Try setting it to 123
- Espresso.onView(withId(android.R.id.edit))
- .perform(replaceText("123"))
-
- // Click "OK"
- Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
- .perform(click())
-
- // Check setting set to 123 in SharedPreferences
- assertEquals(
- 123,
- defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
- )
-
- // Check displaying 123 in summary text
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .onChildView(withId(android.R.id.summary))
- .check(matches(withText("123")))
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun setRecentUploadLimitTo0() {
- // Open "Use external storage" preference
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .perform(click())
-
- // Try setting it to 0
- Espresso.onView(withId(android.R.id.edit))
- .perform(replaceText("0"))
-
- // Click "OK"
- Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
- .perform(click())
-
- // Check setting set to 100 in SharedPreferences
- assertEquals(
- 100,
- defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
- )
-
- // Check displaying 100 in summary text
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .onChildView(withId(android.R.id.summary))
- .check(matches(withText("100")))
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun setRecentUploadLimitTo700() {
- // Open "Use external storage" preference
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .perform(click())
-
- // Try setting it to 700
- Espresso.onView(withId(android.R.id.edit))
- .perform(replaceText("700"))
-
- // Click "OK"
- Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
- .perform(click())
-
- // Check setting set to 500 in SharedPreferences
- assertEquals(
- 500,
- defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
- )
-
- // Check displaying 100 in summary text
- Espresso.onData(PreferenceMatchers.withKey("uploads"))
- .inAdapterView(withId(android.R.id.list))
- .onChildView(withId(android.R.id.summary))
- .check(matches(withText("500")))
- }
-
- @Test
- @Ignore("Fix Failing Test")
fun useAuthorNameTogglesOn() {
// Turn on "Use author name" preference if currently off
if (!defaultKvStore.getBoolean("useAuthorName", false)) {
- Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
- .inAdapterView(withId(android.R.id.list))
- .perform(click())
+ Espresso
+ .onView(
+ allOf(
+ withId(R.id.recycler_view),
+ childAtPosition(withId(android.R.id.list_container), 0),
+ ),
+ ).perform(
+ RecyclerViewActions.actionOnItemAtPosition(6, click()),
+ )
}
-
// Check authorName preference is enabled
- Espresso.onData(PreferenceMatchers.withKey("authorName"))
- .inAdapterView(withId(android.R.id.list))
- .check(matches(isEnabled()))
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun useAuthorNameTogglesOff() {
- // Turn off "Use external storage" preference if currently on
- if (defaultKvStore.getBoolean("useAuthorName", false)) {
- Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
- .inAdapterView(withId(android.R.id.list))
- .perform(click())
- }
-
- // Check authorName preference is enabled
- Espresso.onData(PreferenceMatchers.withKey("authorName"))
- .inAdapterView(withId(android.R.id.list))
- .check(matches(not(isEnabled())))
+ Espresso
+ .onView(
+ allOf(
+ withId(R.id.recycler_view),
+ childAtPosition(withId(android.R.id.list_container), 0),
+ ),
+ ).check(matches(isEnabled()))
}
@Test
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt
deleted file mode 100644
index d36ec8f20..000000000
--- a/app/src/androidTest/java/fr/free/nrw/commons/SignupTest.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package fr.free.nrw.commons
-
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.assertion.ViewAssertions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.intent.Intents.intended
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
-import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.auth.LoginActivity
-import fr.free.nrw.commons.auth.SignupActivity
-import org.junit.Before
-import org.junit.Ignore
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class SignupTest {
- @get:Rule
- var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
-
- @Before
- fun setup() {
- UITestHelper.skipWelcome()
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun testSignupButton() {
- try {
- Intents.init()
- } catch (ex: IllegalStateException) {
-
- }
-
- UITestHelper.sleep(3000)
- Espresso.onView(withId(R.id.sign_up_button))
- .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
- .perform(click())
- intended(hasComponent(SignupActivity::class.java.name))
- Intents.release()
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun orientationChange() {
- UITestHelper.changeOrientation(activityRule)
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
index f5716fea4..ebb06e4af 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt
@@ -2,7 +2,8 @@ package fr.free.nrw.commons
import android.app.Activity
import android.content.pm.ActivityInfo
-import androidx.test.espresso.Espresso.closeSoftKeyboard
+import android.view.View
+import android.view.ViewGroup
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions
@@ -12,36 +13,141 @@ import org.apache.commons.lang3.StringUtils
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.hamcrest.TypeSafeMatcher
import timber.log.Timber
-
class UITestHelper {
companion object {
fun skipWelcome() {
try {
- //Skip tutorial
+ onView(ViewMatchers.withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ // Skip tutorial
onView(ViewMatchers.withId(R.id.finishTutorialButton))
- .perform(ViewActions.click())
+ .perform(ViewActions.click())
+ } catch (ignored: NoMatchingViewException) {
+ }
+ }
+
+ fun skipLogin() {
+ try {
+ // Skip Login
+ val htmlTextView =
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.skip_login),
+ ViewMatchers.withText("Skip"),
+ ViewMatchers.isDisplayed(),
+ ),
+ )
+ htmlTextView.perform(ViewActions.click())
+
+ val appCompatButton =
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withId(android.R.id.button1),
+ ViewMatchers.withText("Yes"),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.buttonPanel),
+ 0,
+ ),
+ 3,
+ ),
+ ),
+ )
+ appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click())
} catch (ignored: NoMatchingViewException) {
}
}
fun loginUser() {
try {
- //Perform Login
+ // Perform Login
sleep(3000)
onView(ViewMatchers.withId(R.id.login_username))
- .perform(ViewActions.clearText(), ViewActions.typeText(getTestUsername()))
- closeSoftKeyboard()
+ .perform(
+ ViewActions.replaceText(getTestUsername()),
+ ViewActions.closeSoftKeyboard(),
+ )
+ sleep(2000)
onView(ViewMatchers.withId(R.id.login_password))
- .perform(ViewActions.clearText(), ViewActions.typeText(getTestUserPassword()))
- closeSoftKeyboard()
+ .perform(
+ ViewActions.replaceText(getTestUserPassword()),
+ ViewActions.closeSoftKeyboard(),
+ )
+ sleep(2000)
onView(ViewMatchers.withId(R.id.login_button))
- .perform(ViewActions.click())
+ .perform(ViewActions.click())
sleep(10000)
} catch (ignored: NoMatchingViewException) {
}
+ }
+ fun logoutUser() {
+ try {
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withContentDescription("More"),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 4,
+ ),
+ ViewMatchers.isDisplayed(),
+ ),
+ ).perform(ViewActions.click())
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withId(R.id.more_logout),
+ ViewMatchers.withText("Logout"),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet),
+ 0,
+ ),
+ 6,
+ ),
+ ),
+ ).perform(ViewActions.scrollTo(), ViewActions.click())
+ onView(
+ Matchers.allOf(
+ ViewMatchers.withId(android.R.id.button1),
+ ViewMatchers.withText("Yes"),
+ childAtPosition(
+ childAtPosition(
+ ViewMatchers.withId(R.id.buttonPanel),
+ 0,
+ ),
+ 3,
+ ),
+ ),
+ ).perform(ViewActions.scrollTo(), ViewActions.click())
+ sleep(5000)
+ } catch (ignored: NoMatchingViewException) {
+ }
+ }
+
+ fun childAtPosition(
+ parentMatcher: Matcher,
+ position: Int,
+ ): Matcher {
+ return object : TypeSafeMatcher() {
+ override fun describeTo(description: Description) {
+ description.appendText("Child at position $position in parent ")
+ parentMatcher.describeTo(description)
+ }
+
+ public override fun matchesSafely(view: View): Boolean {
+ val parent = view.parent
+ return parent is ViewGroup &&
+ parentMatcher.matches(parent) &&
+ view == parent.getChildAt(position)
+ }
+ }
}
fun sleep(timeInMillis: Long) {
@@ -57,16 +163,21 @@ class UITestHelper {
val username = BuildConfig.TEST_USERNAME
if (StringUtils.isEmpty(username) || username == "null") {
throw NotImplementedError("Configure your beta account's username")
- } else return username
+ } else {
+ return username
+ }
}
private fun getTestUserPassword(): String {
val password = BuildConfig.TEST_PASSWORD
if (StringUtils.isEmpty(password) || password == "null") {
throw NotImplementedError("Configure your beta account's password")
- } else return password
+ } else {
+ return password
+ }
}
- fun changeOrientation(activityRule: ActivityTestRule){
+
+ fun changeOrientation(activityRule: ActivityTestRule) {
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
@@ -76,6 +187,7 @@ class UITestHelper {
fun first(matcher: Matcher): Matcher? {
return object : BaseMatcher() {
var isFirst = true
+
override fun matches(item: Any): Boolean {
if (isFirst && matcher.matches(item)) {
isFirst = false
@@ -90,4 +202,4 @@ class UITestHelper {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
index d33f958c1..d3a814f2d 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt
@@ -1,18 +1,8 @@
package fr.free.nrw.commons
-import android.net.Uri
-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.rule.ActivityTestRule
-import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.upload.UploadActivity
-import fr.free.nrw.commons.upload.depicts.DepictsFragment
-import org.hamcrest.Matchers
-import org.hamcrest.core.AllOf
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -26,23 +16,4 @@ class UploadActivityTest {
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
-
- @Test
- @Ignore("Fix Failing Test")
- fun TestForCaptionsAndDepictions() {
- val imageUri = Uri.parse("file://mnt/sdcard/image.jpg")
-
- Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text))
- .perform(ViewActions.typeText("caption in english"))
- Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text))
- .perform(ViewActions.typeText("description in english"))
- Espresso.onData(AllOf.allOf(Matchers.anything("spinner text"))).atPosition(1).perform(ViewActions.click());
- Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text))
- .perform(ViewActions.typeText("caption in some other language"))
- Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text))
- .perform(ViewActions.typeText("description in some other language"))
- Espresso.onView(ViewMatchers.withId(R.id.btn_next))
- .perform(ViewActions.click())
- Intents.intended(IntentMatchers.hasComponent(DepictsFragment::class.java.name))
- }
}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt
new file mode 100644
index 000000000..c3d3dc3c3
--- /dev/null
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt
@@ -0,0 +1,203 @@
+package fr.free.nrw.commons
+
+import android.app.Activity
+import android.app.Instrumentation
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
+import androidx.test.espresso.action.ViewActions.replaceText
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.ActivityTestRule
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import fr.free.nrw.commons.locationpicker.LocationPickerActivity
+import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
+import fr.free.nrw.commons.auth.LoginActivity
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matchers.allOf
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UploadCancelledTest {
+ @Rule
+ @JvmField
+ var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
+
+ @Rule
+ @JvmField
+ var mGrantPermissionRule: GrantPermissionRule =
+ GrantPermissionRule.grant(
+ "android.permission.WRITE_EXTERNAL_STORAGE",
+ )
+
+ private val device: UiDevice =
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setup() {
+ try {
+ Intents.init()
+ } catch (ex: 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 (ex: IllegalStateException) {
+ }
+ }
+
+ @Test
+ fun uploadCancelledAfterLocationPickedTest() {
+ val bottomNavigationItemView =
+ onView(
+ allOf(
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.fragment_main_nav_tab_layout),
+ 0,
+ ),
+ 1,
+ ),
+ isDisplayed(),
+ ),
+ )
+ bottomNavigationItemView.perform(click())
+
+ UITestHelper.sleep(12000)
+
+ val actionMenuItemView =
+ onView(
+ allOf(
+ withId(R.id.list_sheet),
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.toolbar),
+ 1,
+ ),
+ 0,
+ ),
+ isDisplayed(),
+ ),
+ )
+ actionMenuItemView.perform(click())
+
+ val recyclerView =
+ onView(
+ allOf(
+ withId(R.id.rv_nearby_list),
+ ),
+ )
+ recyclerView.perform(
+ RecyclerViewActions.actionOnItemAtPosition(
+ 0,
+ click(),
+ ),
+ )
+
+ val linearLayout3 =
+ onView(
+ allOf(
+ withId(R.id.cameraButton),
+ childAtPosition(
+ allOf(
+ withId(R.id.nearby_button_layout),
+ ),
+ 0,
+ ),
+ isDisplayed(),
+ ),
+ )
+ linearLayout3.perform(click())
+
+ val pasteSensitiveTextInputEditText =
+ onView(
+ allOf(
+ withId(R.id.caption_item_edit_text),
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.caption_item_edit_text_input_layout),
+ 0,
+ ),
+ 0,
+ ),
+ isDisplayed(),
+ ),
+ )
+ pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard())
+
+ val pasteSensitiveTextInputEditText2 =
+ onView(
+ allOf(
+ withId(R.id.description_item_edit_text),
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.description_item_edit_text_input_layout),
+ 0,
+ ),
+ 0,
+ ),
+ isDisplayed(),
+ ),
+ )
+ pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard())
+
+ val appCompatButton2 =
+ onView(
+ allOf(
+ withId(R.id.btn_next),
+ childAtPosition(
+ childAtPosition(
+ withId(R.id.ll_container_media_detail),
+ 2,
+ ),
+ 1,
+ ),
+ isDisplayed(),
+ ),
+ )
+ appCompatButton2.perform(click())
+
+ val appCompatButton3 =
+ onView(
+ allOf(
+ withId(android.R.id.button1),
+ ),
+ )
+ appCompatButton3.perform(scrollTo(), click())
+
+ Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name))
+
+ val floatingActionButton3 =
+ onView(
+ allOf(
+ withId(R.id.location_chosen_button),
+ isDisplayed(),
+ ),
+ )
+ UITestHelper.sleep(2000)
+ floatingActionButton3.perform(click())
+ }
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
index 931259801..88c7e5d3d 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt
@@ -8,7 +8,6 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.view.View
-import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions.click
@@ -20,31 +19,42 @@ import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
-import androidx.test.espresso.matcher.ViewMatchers.*
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withParent
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
-import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.util.MyViewAction
import fr.free.nrw.commons.utils.ConfigUtils
import org.hamcrest.core.AllOf.allOf
-import org.junit.*
+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.*
+import java.util.Date
+import java.util.Random
@LargeTest
@RunWith(AndroidJUnit4::class)
class UploadTest {
@get:Rule
- var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE,
- Manifest.permission.ACCESS_FINE_LOCATION)!!
+ var permissionRule =
+ GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ )!!
@get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java)
@@ -62,10 +72,9 @@ class UploadTest {
try {
Intents.init()
} catch (ex: IllegalStateException) {
-
}
- UITestHelper.skipWelcome()
UITestHelper.loginUser()
+ UITestHelper.skipWelcome()
}
@After
@@ -95,14 +104,13 @@ class UploadTest {
dismissWarning("Yes")
onView(allOf(isDisplayed(), withId(R.id.tv_title)))
- .perform(replaceText(commonsFileName))
+ .perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.description_item_edit_text)))
- .perform(replaceText(commonsFileName))
-
+ .perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(5000)
dismissWarning("Yes")
@@ -110,29 +118,30 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
- .perform(replaceText("Uploaded with Mobile/Android Tests"))
+ .perform(replaceText("Uploaded with Mobile/Android Tests"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
- .perform(click())
+ .perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(10000)
- val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+ val fileUrl =
+ "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@@ -140,8 +149,8 @@ class UploadTest {
private fun dismissWarning(warningText: String) {
try {
onView(withText(warningText))
- .check(matches(isDisplayed()))
- .perform(click())
+ .check(matches(isDisplayed()))
+ .perform(click())
} catch (ignored: NoMatchingViewException) {
}
}
@@ -168,10 +177,10 @@ class UploadTest {
dismissWarning("Yes")
onView(allOf(isDisplayed(), withId(R.id.tv_title)))
- .perform(replaceText(commonsFileName))
+ .perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(10000)
dismissWarning("Yes")
@@ -179,29 +188,30 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
- .perform(replaceText("Test"))
+ .perform(replaceText("Test"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
- .perform(click())
+ .perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(10000)
- val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+ val fileUrl =
+ "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@@ -228,23 +238,29 @@ class UploadTest {
dismissWarningDialog()
onView(allOf(isDisplayed(), withId(R.id.tv_title)))
- .perform(replaceText(commonsFileName))
+ .perform(replaceText(commonsFileName))
onView(withId(R.id.rv_descriptions)).perform(
- RecyclerViewActions
- .actionOnItemAtPosition(0,
- MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description")))
+ RecyclerViewActions
+ .actionOnItemAtPosition(
+ 0,
+ MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"),
+ ),
+ )
- onView(withId(R.id.btn_add_description))
- .perform(click())
+ onView(withId(R.id.btn_add))
+ .perform(click())
onView(withId(R.id.rv_descriptions)).perform(
- RecyclerViewActions
- .actionOnItemAtPosition(1,
- MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description")))
+ RecyclerViewActions
+ .actionOnItemAtPosition(
+ 1,
+ MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"),
+ ),
+ )
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(5000)
dismissWarning("Yes")
@@ -252,29 +268,30 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
- .perform(replaceText("Test"))
+ .perform(replaceText("Test"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
- .perform(click())
+ .perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
- .perform(click())
+ .perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
- .perform(click())
+ .perform(click())
UITestHelper.sleep(10000)
- val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
+ val fileUrl =
+ "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@@ -307,7 +324,6 @@ class UploadTest {
} catch (e: IOException) {
e.printStackTrace()
}
-
}
}
@@ -329,8 +345,8 @@ class UploadTest {
private fun dismissWarningDialog() {
try {
onView(withText("Yes"))
- .check(matches(isDisplayed()))
- .perform(click())
+ .check(matches(isDisplayed()))
+ .perform(click())
} catch (ignored: NoMatchingViewException) {
}
}
@@ -338,10 +354,10 @@ class UploadTest {
private fun openGallery() {
// Open FAB
onView(allOf(withId(R.id.fab_plus), isDisplayed()))
- .perform(click())
+ .perform(click())
// Click gallery
onView(allOf(withId(R.id.fab_gallery), isDisplayed()))
- .perform(click())
+ .perform(click())
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
index 764b892d2..5956b3c02 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt
@@ -5,107 +5,129 @@ 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.internal.runner.junit4.AndroidJUnit4ClassRunner
+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.Ignore
+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(AndroidJUnit4ClassRunner::class)
+@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
- @Ignore("Fix Failing Test")
fun ifBetaShowsSkipButton() {
if (ConfigUtils.isBetaFlavour) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
onView(withId(R.id.finishTutorialButton))
- .check(matches(isDisplayed()))
+ .check(matches(isDisplayed()))
}
}
@Test
fun ifProdHidesSkipButton() {
if (!ConfigUtils.isBetaFlavour) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
onView(withId(R.id.finishTutorialButton))
- .check(matches(not(isDisplayed())))
+ .check(matches(not(isDisplayed())))
}
}
@Test
- @Ignore("Fix Failing Test")
fun testBetaSkipButton() {
if (ConfigUtils.isBetaFlavour) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
onView(withId(R.id.finishTutorialButton))
- .perform(ViewActions.click())
- assert(activityRule.activity.isDestroyed)
+ .perform(ViewActions.click())
+ assertThat(activityRule.activity.isDestroyed, equalTo(true))
}
}
@Test
- @Ignore("Fix Failing Test")
fun testSwipingOnce() {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeLeft())
- assert(true)
+ .perform(ViewActions.swipeLeft())
+ assertThat(true, equalTo(true))
onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeRight())
- assert(true)
+ .perform(ViewActions.swipeRight())
+ assertThat(true, equalTo(true))
}
@Test
- @Ignore("Fix Failing Test")
fun testSwipingWholeTutorial() {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeLeft())
- .perform(ViewActions.swipeLeft())
- .perform(ViewActions.swipeLeft())
- .perform(ViewActions.swipeLeft())
- assert(true)
+ .perform(ViewActions.swipeLeft())
+ .perform(ViewActions.swipeLeft())
+ .perform(ViewActions.swipeLeft())
+ .perform(ViewActions.swipeLeft())
+ assertThat(true, equalTo(true))
onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeRight())
- .perform(ViewActions.swipeRight())
- .perform(ViewActions.swipeRight())
- .perform(ViewActions.swipeRight())
- assert(true)
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ .perform(ViewActions.swipeRight())
+ assertThat(true, equalTo(true))
}
@Test
- fun swipeBeyondBounds(){
- var view_pager=activityRule.activity.findViewById(R.id.welcomePager)
+ fun swipeBeyondBounds() {
+ val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
- view_pager.adapter?.let { view_pager.currentItem == view_pager.adapter?.count?.minus(1)
- if (view_pager.currentItem==3){
- onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeLeft())
- assert(true)
- onView(withId(R.id.welcomePager))
- .perform(ViewActions.swipeRight())
- assert(false)
- }}
+ viewPager.adapter?.let {
+ if (viewPager.currentItem == 3) {
+ onView(withId(R.id.welcomePager))
+ .perform(ViewActions.swipeLeft())
+ assertThat(true, equalTo(true))
+ onView(withId(R.id.welcomePager))
+ .perform(ViewActions.swipeRight())
+ assertThat(true, equalTo(true))
+ }
+ }
}
@Test
- fun swipeTillLastAndFinish(){
- var view_pager=activityRule.activity.findViewById(R.id.welcomePager)
+ fun swipeTillLastAndFinish() {
+ val viewPager = activityRule.activity.findViewById(R.id.welcomePager)
- view_pager.adapter?.let { view_pager.currentItem == view_pager.adapter?.count?.minus(1)
- if (view_pager.currentItem==3){
- onView(withId(R.id.finishTutorialButton))
- .perform(ViewActions.click())
- assert(activityRule.activity.isDestroyed)
- }}
+ viewPager.adapter?.let {
+ if (viewPager.currentItem == 3) {
+ onView(withId(R.id.button_ok))
+ .perform(ViewActions.click())
+ onView(withId(R.id.finishTutorialButton))
+ .perform(ViewActions.click())
+ assertThat(activityRule.activity.isDestroyed, equalTo(true))
+ }
+ }
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
index ebfa8b0c7..647c5bbda 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt
@@ -1,72 +1,34 @@
package fr.free.nrw.commons.ui
-import android.R
import android.content.Context
-import android.os.Build
import android.util.AttributeSet
import androidx.test.core.app.ApplicationProvider
-import androidx.test.runner.AndroidJUnit4
-import fr.free.nrw.commons.ui.PasteSensitiveTextInputEditText
+import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert
import org.junit.Before
-import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
-import java.lang.Exception
-import kotlin.Throws
@RunWith(AndroidJUnit4::class)
class PasteSensitiveTextInputEditTextTest {
-
private var context: Context? = null
private var textView: PasteSensitiveTextInputEditText? = null
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
- textView = PasteSensitiveTextInputEditText(context)
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun onTextContextMenuItemPasteFormattingDisabled() {
- textView!!.setFormattingAllowed(false);
- textView!!.setText("Text")
- textView!!.onTextContextMenuItem(R.id.paste)
- Assert.assertEquals("Text", textView!!.text.toString())
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun onTextContextMenuItemPasteFormattingAllowed() {
- textView!!.setFormattingAllowed(true);
- textView!!.setText("Text")
- textView!!.onTextContextMenuItem(R.id.paste)
- Assert.assertEquals("Text", textView!!.text.toString())
- }
-
- @Test
- @Ignore("Fix Failing Test")
- fun onTextContextMenuItemPaste() {
- textView!!.setText("Text")
- textView!!.onTextContextMenuItem(R.id.paste)
- Assert.assertEquals("Text", textView!!.text.toString())
- }
-
-
- @Test
- @Ignore("Fix Failing Test")
- fun onTextContextMenuItemNotPaste() {
- textView!!.setText("Text")
- textView!!.onTextContextMenuItem(R.id.copy)
- Assert.assertEquals("Text", textView!!.text.toString())
+ 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)
+ fun extractFormattingAttributeSet() {
+ val methodExtractFormattingAttribute =
+ textView!!.javaClass.getDeclaredMethod(
+ "extractFormattingAttribute",
+ Context::class.java,
+ AttributeSet::class.java,
+ )
methodExtractFormattingAttribute.isAccessible = true
methodExtractFormattingAttribute.invoke(textView, context, null)
}
@@ -81,4 +43,4 @@ class PasteSensitiveTextInputEditTextTest {
textView!!.setFormattingAllowed(false)
Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView))
}
-}
\ 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
index 955c712b9..52ac18e4d 100644
--- a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
+++ b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt
@@ -9,56 +9,58 @@ import org.hamcrest.Matcher
class MyViewAction {
companion object {
- fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction {
- return object : ViewAction {
- override fun getConstraints(): Matcher? {
- return null
- }
+ fun typeTextInChildViewWithId(
+ id: Int,
+ textToBeTyped: String,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
- override fun getDescription(): String {
- return "Click on a child view with specified id."
- }
+ override fun getDescription(): String = "Click on a child view with specified id."
- override fun perform(uiController: UiController, view: View) {
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
val v = view.findViewById(id) as EditText
v.setText(textToBeTyped)
}
}
- }
- fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction {
- return object : ViewAction {
- override fun getConstraints(): Matcher? {
- return null
- }
+ fun selectSpinnerItemInChildViewWithId(
+ id: Int,
+ position: Int,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
- override fun getDescription(): String {
- return "Click on a child view with specified id."
- }
+ override fun getDescription(): String = "Click on a child view with specified id."
- override fun perform(uiController: UiController, view: View) {
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
val v = view.findViewById(id) as AppCompatSpinner
v.setSelection(position)
}
}
- }
- fun clickItemWithId(id: Int, position: Int): ViewAction {
- return object : ViewAction {
- override fun getConstraints(): Matcher? {
- return null
- }
+ fun clickItemWithId(
+ id: Int,
+ position: Int,
+ ): ViewAction =
+ object : ViewAction {
+ override fun getConstraints(): Matcher? = null
- override fun getDescription(): String {
- return "Click on a child view with specified id."
- }
+ override fun getDescription(): String = "Click on a child view with specified id."
- override fun perform(uiController: UiController, view: View) {
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
val v = view.findViewById(id) as View
v.performClick()
}
}
- }
-
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8babf5cf7..fb776920e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,232 +1,265 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
+
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
+
+
+
+
+
+
-
-
+
-
+
+
+
+
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
index f75692e5a..dcc9bfd43 100644
--- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java
@@ -1,7 +1,6 @@
package fr.free.nrw.commons;
import android.annotation.SuppressLint;
-import android.app.AlertDialog;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
@@ -16,6 +15,7 @@ import androidx.annotation.NonNull;
import fr.free.nrw.commons.databinding.ActivityAboutBinding;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.ConfigUtils;
+import fr.free.nrw.commons.utils.DialogUtil;
import java.util.Collections;
import java.util.List;
@@ -64,6 +64,7 @@ public class AboutActivity extends BaseActivity {
Utils.setUnderlinedText(binding.aboutFaq, R.string.about_faq, getApplicationContext());
Utils.setUnderlinedText(binding.aboutRateUs, R.string.about_rate_us, getApplicationContext());
+ Utils.setUnderlinedText(binding.aboutUserGuide, R.string.user_guide, getApplicationContext());
Utils.setUnderlinedText(binding.aboutPrivacyPolicy, R.string.about_privacy_policy, getApplicationContext());
Utils.setUnderlinedText(binding.aboutTranslate, R.string.about_translate, getApplicationContext());
Utils.setUnderlinedText(binding.aboutCredits, R.string.about_credits, getApplicationContext());
@@ -77,6 +78,7 @@ public class AboutActivity extends BaseActivity {
binding.aboutRateUs.setOnClickListener(this::launchRatings);
binding.aboutCredits.setOnClickListener(this::launchCredits);
binding.aboutPrivacyPolicy.setOnClickListener(this::launchPrivacyPolicy);
+ binding.aboutUserGuide.setOnClickListener(this::launchUserGuide);
binding.aboutFaq.setOnClickListener(this::launchFrequentlyAskedQuesions);
binding.aboutTranslate.setOnClickListener(this::launchTranslate);
}
@@ -99,7 +101,14 @@ public class AboutActivity extends BaseActivity {
}
public void launchGithub(View view) {
- Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
+ Intent intent;
+ try {
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL));
+ intent.setPackage(Urls.GITHUB_PACKAGE_NAME);
+ startActivity(intent);
+ } catch (Exception e) {
+ Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
+ }
}
public void launchWebsite(View view) {
@@ -114,6 +123,10 @@ public class AboutActivity extends BaseActivity {
Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL));
}
+ public void launchUserGuide(View view) {
+ Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL));
+ }
+
public void launchPrivacyPolicy(View view) {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
}
@@ -155,17 +168,20 @@ public class AboutActivity extends BaseActivity {
spinner.setAdapter(languageAdapter);
spinner.setGravity(17);
spinner.setPadding(50,0,0,0);
- AlertDialog.Builder builder = new AlertDialog.Builder(AboutActivity.this);
- builder.setView(spinner);
- builder.setTitle(R.string.about_translate_title)
- .setMessage(R.string.about_translate_message)
- .setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> {
- String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
- Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
- });
- builder.setNegativeButton(R.string.about_translate_cancel, (dialog, which) -> dialog.cancel());
- builder.create().show();
+ Runnable positiveButtonRunnable = () -> {
+ String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
+ Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
+ };
+ DialogUtil.showAlertDialog(this,
+ getString(R.string.about_translate_title),
+ getString(R.string.about_translate_message),
+ getString(R.string.about_translate_proceed),
+ getString(R.string.about_translate_cancel),
+ positiveButtonRunnable,
+ () -> {},
+ spinner
+ );
}
}
diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
new file mode 100644
index 000000000..28b01d603
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
@@ -0,0 +1,63 @@
+package fr.free.nrw.commons
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import fr.free.nrw.commons.location.LatLng
+import fr.free.nrw.commons.nearby.Place
+
+class BaseMarker {
+ private var _position: LatLng = LatLng(0.0, 0.0, 0f)
+ private var _title: String = ""
+ private var _place: Place = Place()
+ private var _icon: Bitmap? = null
+
+ var position: LatLng
+ get() = _position
+ set(value) {
+ _position = value
+ }
+ var title: String
+ get() = _title
+ set(value) {
+ _title = value
+ }
+
+ var place: Place
+ get() = _place
+ set(value) {
+ _place = value
+ }
+ var icon: Bitmap?
+ get() = _icon
+ set(value) {
+ _icon = value
+ }
+
+ constructor() {
+ }
+
+ fun fromResource(
+ context: Context,
+ drawableResId: Int,
+ ) {
+ val drawable: Drawable = context.resources.getDrawable(drawableResId)
+ icon =
+ if (drawable is BitmapDrawable) {
+ drawable.bitmap
+ } else {
+ val bitmap =
+ Bitmap.createBitmap(
+ drawable.intrinsicWidth,
+ drawable.intrinsicHeight,
+ Bitmap.Config.ARGB_8888,
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ bitmap
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
index 018d787f9..c0c0b9a61 100644
--- a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
+++ b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt
@@ -10,9 +10,10 @@ object BetaConstants {
* 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"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
new file mode 100644
index 000000000..e3a644c6a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
@@ -0,0 +1,33 @@
+package fr.free.nrw.commons
+
+import android.os.Parcel
+import android.os.Parcelable
+
+class CameraPosition(
+ val latitude: Double,
+ val longitude: Double,
+ val zoom: Double,
+) : Parcelable {
+ constructor(parcel: Parcel) : this(
+ parcel.readDouble(),
+ parcel.readDouble(),
+ parcel.readDouble(),
+ )
+
+ override fun writeToParcel(
+ parcel: Parcel,
+ flags: Int,
+ ) {
+ parcel.writeDouble(latitude)
+ parcel.writeDouble(longitude)
+ parcel.writeDouble(zoom)
+ }
+
+ override fun describeContents(): Int = 0
+
+ companion object CREATOR : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel)
+
+ override fun newArray(size: Int): Array = arrayOfNulls(size)
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java b/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
deleted file mode 100644
index 8b6ca47e0..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package fr.free.nrw.commons;
-
-import androidx.annotation.NonNull;
-
-import org.wikipedia.AppAdapter;
-import org.wikipedia.dataclient.SharedPreferenceCookieManager;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.json.GsonMarshaller;
-import org.wikipedia.json.GsonUnmarshaller;
-import org.wikipedia.login.LoginResult;
-
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import okhttp3.OkHttpClient;
-
-public class CommonsAppAdapter extends AppAdapter {
- private final int DEFAULT_THUMB_SIZE = 640;
- private final String COOKIE_STORE_NAME = "cookie_store";
-
- private final SessionManager sessionManager;
- private final JsonKvStore preferences;
-
- CommonsAppAdapter(@NonNull SessionManager sessionManager, @NonNull JsonKvStore preferences) {
- this.sessionManager = sessionManager;
- this.preferences = preferences;
- }
-
- @Override
- public String getMediaWikiBaseUrl() {
- return BuildConfig.COMMONS_URL;
- }
-
- @Override
- public String getRestbaseUriFormat() {
- return BuildConfig.COMMONS_URL;
- }
-
- @Override
- public OkHttpClient getOkHttpClient(@NonNull WikiSite wikiSite) {
- return OkHttpConnectionFactory.getClient();
- }
-
- @Override
- public int getDesiredLeadImageDp() {
- return DEFAULT_THUMB_SIZE;
- }
-
- @Override
- public boolean isLoggedIn() {
- return sessionManager.isUserLoggedIn();
- }
-
- @Override
- public String getUserName() {
- return sessionManager.getUserName();
- }
-
- @Override
- public String getPassword() {
- return sessionManager.getPassword();
- }
-
- @Override
- public void updateAccount(@NonNull LoginResult result) {
- sessionManager.updateAccount(result);
- }
-
- @Override
- public SharedPreferenceCookieManager getCookies() {
- if (!preferences.contains(COOKIE_STORE_NAME)) {
- return null;
- }
- return GsonUnmarshaller.unmarshal(SharedPreferenceCookieManager.class,
- preferences.getString(COOKIE_STORE_NAME, null));
- }
-
- @Override
- public void setCookies(@NonNull SharedPreferenceCookieManager cookies) {
- preferences.putString(COOKIE_STORE_NAME, GsonMarshaller.marshal(cookies));
- }
-
- @Override
- public boolean logErrorsInsteadOfCrashing() {
- return false;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
deleted file mode 100644
index e95196bbb..000000000
--- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
+++ /dev/null
@@ -1,365 +0,0 @@
-package fr.free.nrw.commons;
-
-import static fr.free.nrw.commons.data.DBOpenHelper.CONTRIBUTIONS_TABLE;
-import static org.acra.ReportField.ANDROID_VERSION;
-import static org.acra.ReportField.APP_VERSION_CODE;
-import static org.acra.ReportField.APP_VERSION_NAME;
-import static org.acra.ReportField.PHONE_MODEL;
-import static org.acra.ReportField.STACK_TRACE;
-import static org.acra.ReportField.USER_COMMENT;
-
-import android.annotation.SuppressLint;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.content.Context;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-import android.os.Build;
-import android.os.Process;
-import android.util.Log;
-import androidx.annotation.NonNull;
-import androidx.multidex.BuildConfig;
-import androidx.multidex.MultiDexApplication;
-import com.facebook.drawee.backends.pipeline.Fresco;
-import com.facebook.imagepipeline.core.ImagePipeline;
-import com.facebook.imagepipeline.core.ImagePipelineConfig;
-import com.mapbox.mapboxsdk.Mapbox;
-import com.squareup.leakcanary.LeakCanary;
-import com.squareup.leakcanary.RefWatcher;
-import fr.free.nrw.commons.auth.SessionManager;
-import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao.Table;
-import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
-import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
-import fr.free.nrw.commons.category.CategoryDao;
-import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler;
-import fr.free.nrw.commons.concurrency.ThreadPoolService;
-import fr.free.nrw.commons.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.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;
-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 java.io.File;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Named;
-import org.acra.ACRA;
-import org.acra.annotation.AcraCore;
-import org.acra.annotation.AcraDialog;
-import org.acra.annotation.AcraMailSender;
-import org.acra.data.StringFormat;
-import org.wikipedia.AppAdapter;
-import org.wikipedia.language.AppLanguageLookUpTable;
-import timber.log.Timber;
-
-@AcraCore(
- buildConfigClass = BuildConfig.class,
- resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
- reportFormat = StringFormat.KEY_VALUE_LIST,
- reportContent = {USER_COMMENT, APP_VERSION_CODE, APP_VERSION_NAME, ANDROID_VERSION, PHONE_MODEL,
- STACK_TRACE}
-)
-
-@AcraMailSender(
- mailTo = "commons-app-android-private@googlegroups.com",
- 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
-)
-
-public class CommonsApplication extends MultiDexApplication {
-
- public static final String IS_LIMITED_CONNECTION_MODE_ENABLED = "is_limited_connection_mode_enabled";
- @Inject
- SessionManager sessionManager;
- @Inject
- DBOpenHelper dbOpenHelper;
-
- @Inject
- @Named("default_preferences")
- JsonKvStore defaultPrefs;
-
- @Inject
- CustomOkHttpNetworkFetcher customOkHttpNetworkFetcher;
-
- /**
- * Constants begin
- */
- public static final int OPEN_APPLICATION_DETAIL_SETTINGS = 1001;
-
- public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using [[COM:MOA|Commons Mobile App]]";
-
- public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
-
- public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App Feedback";
-
- public static final String NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll";
-
- public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --";
-
- /**
- * Constants End
- */
-
- private RefWatcher refWatcher;
-
- private static CommonsApplication INSTANCE;
-
- public static CommonsApplication getInstance() {
- return INSTANCE;
- }
-
- private AppLanguageLookUpTable languageLookUpTable;
-
- public AppLanguageLookUpTable getLanguageLookUpTable() {
- return languageLookUpTable;
- }
-
- @Inject
- ContributionDao contributionDao;
-
- /**
- * In-memory list of contributions whose uploads have been paused by the user
- */
- public static Map pauseUploads = new HashMap<>();
-
- /**
- * Used to declare and initialize various components and dependencies
- */
- @Override
- public void onCreate() {
- super.onCreate();
-
- INSTANCE = this;
- ACRA.init(this);
- Mapbox.getInstance(this, getString(R.string.mapbox_commons_app_token));
-
- ApplicationlessInjection
- .getInstance(this)
- .getCommonsApplicationComponent()
- .inject(this);
-
- AppAdapter.set(new CommonsAppAdapter(sessionManager, defaultPrefs));
-
- initTimber();
-
- if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
- Set defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS);
- if (null == defaultExifTagsSet) {
- defaultExifTagsSet = new HashSet<>();
- }
- defaultExifTagsSet.add(getString(R.string.exif_tag_location));
- defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet);
- }
-
-// Set DownsampleEnabled to True to downsample the image in case it's heavy
- ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
- .setNetworkFetcher(customOkHttpNetworkFetcher)
- .setDownsampleEnabled(true)
- .build();
- try {
- Fresco.initialize(this, config);
- } catch (Exception e) {
- Timber.e(e);
- // TODO: Remove when we're able to initialize Fresco in test builds.
- }
-
- createNotificationChannel(this);
-
- languageLookUpTable = new AppLanguageLookUpTable(this);
-
- // This handler will catch exceptions thrown from Observables after they are disposed,
- // or from Observables that are (deliberately or not) missing an onError handler.
- RxJavaPlugins.setErrorHandler(Functions.emptyConsumer());
-
- if (setupLeakCanary() == RefWatcher.DISABLED) {
- return;
- }
- // Fire progress callbacks for every 3% of uploaded content
- System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
- }
-
- /**
- * Plants debug and file logging tree. Timber lets you plant your own logging trees.
- */
- private void initTimber() {
- boolean isBeta = ConfigUtils.isBetaFlavour();
- String logFileName =
- isBeta ? "CommonsBetaAppLogs" : "CommonsAppLogs";
- String logDirectory = LogUtils.getLogDirectory();
- //Delete stale logs if they have exceeded the specified size
- deleteStaleLogs(logFileName, logDirectory);
-
- FileLoggingTree tree = new FileLoggingTree(
- Log.VERBOSE,
- logFileName,
- logDirectory,
- 1000,
- getFileLoggingThreadPool());
-
- Timber.plant(tree);
- Timber.plant(new Timber.DebugTree());
- }
-
- /**
- * Deletes the logs zip file at the specified directory and file locations specified in the
- * params
- *
- * @param logFileName
- * @param logDirectory
- */
- private void deleteStaleLogs(String logFileName, String logDirectory) {
- try {
- File file = new File(logDirectory + "/zip/" + logFileName + ".zip");
- if (file.exists() && file.getTotalSpace() > 1000000) {// In Kbs
- file.delete();
- }
- } catch (Exception e) {
- Timber.e(e);
- }
- }
-
- public static boolean isRoboUnitTest() {
- return "robolectric".equals(Build.FINGERPRINT);
- }
-
- private ThreadPoolService getFileLoggingThreadPool() {
- return new ThreadPoolService.Builder("file-logging-thread")
- .setPriority(Process.THREAD_PRIORITY_LOWEST)
- .setPoolSize(1)
- .setExceptionHandler(new BackgroundPoolExceptionHandler())
- .build();
- }
-
- public static void createNotificationChannel(@NonNull Context context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- NotificationManager manager = (NotificationManager) context
- .getSystemService(Context.NOTIFICATION_SERVICE);
- NotificationChannel channel = manager
- .getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL);
- if (channel == null) {
- channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID_ALL,
- context.getString(R.string.notifications_channel_name_all),
- NotificationManager.IMPORTANCE_DEFAULT);
- manager.createNotificationChannel(channel);
- }
- }
- }
-
- public String getUserAgent() {
- return "Commons/" + ConfigUtils.getVersionNameWithSha(this)
- + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE;
- }
-
- /**
- * Helps in setting up LeakCanary library
- *
- * @return instance of LeakCanary
- */
- protected RefWatcher setupLeakCanary() {
- if (LeakCanary.isInAnalyzerProcess(this)) {
- return RefWatcher.DISABLED;
- }
- return LeakCanary.install(this);
- }
-
- /**
- * Provides a way to get member refWatcher
- *
- * @param context Application context
- * @return application member refWatcher
- */
- public static RefWatcher getRefWatcher(Context context) {
- CommonsApplication application = (CommonsApplication) context.getApplicationContext();
- return application.refWatcher;
- }
-
- /**
- * clears data of current application
- *
- * @param context Application context
- * @param logoutListener Implementation of interface LogoutListener
- */
- @SuppressLint("CheckResult")
- public void clearApplicationData(Context context, LogoutListener logoutListener) {
- File cacheDirectory = context.getCacheDir();
- File applicationDirectory = new File(cacheDirectory.getParent());
- if (applicationDirectory.exists()) {
- String[] fileNames = applicationDirectory.list();
- for (String fileName : fileNames) {
- if (!fileName.equals("lib")) {
- FileUtils.deleteFile(new File(applicationDirectory, fileName));
- }
- }
- }
-
- sessionManager.logout()
- .andThen(Completable.fromAction(() -> {
- Timber.d("All accounts have been removed");
- clearImageCache();
- //TODO: fix preference manager
- defaultPrefs.clearAll();
- defaultPrefs.putBoolean("firstrun", false);
- updateAllDatabases();
- }
- ))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(logoutListener::onLogoutComplete, Timber::e);
- }
-
- /**
- * Clear all images cache held by Fresco
- */
- private void clearImageCache() {
- ImagePipeline imagePipeline = Fresco.getImagePipeline();
- imagePipeline.clearCaches();
- }
-
- /**
- * Deletes all tables and re-creates them.
- */
- private void updateAllDatabases() {
- dbOpenHelper.getReadableDatabase().close();
- SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
-
- CategoryDao.Table.onDelete(db);
- dbOpenHelper.deleteTable(db,
- CONTRIBUTIONS_TABLE);//Delete the contributions table in the existing db on older versions
-
- try {
- contributionDao.deleteAll();
- } catch (SQLiteException e) {
- Timber.e(e);
- }
- BookmarkPicturesDao.Table.onDelete(db);
- BookmarkLocationsDao.Table.onDelete(db);
- Table.onDelete(db);
- }
-
-
- /**
- * Interface used to get log-out events
- */
- public interface LogoutListener {
-
- void onLogoutComplete();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
new file mode 100644
index 000000000..9ed19d686
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
@@ -0,0 +1,414 @@
+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.BookmarkItemsDao
+import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
+import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao
+import fr.free.nrw.commons.category.CategoryDao
+import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
+import fr.free.nrw.commons.concurrency.ThreadPoolService
+import fr.free.nrw.commons.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
+
+ try {
+ contributionDao.deleteAll()
+ } catch (e: SQLiteException) {
+ Timber.e(e)
+ }
+ BookmarkPicturesDao.Table.onDelete(db)
+ BookmarkLocationsDao.Table.onDelete(db)
+ BookmarkItemsDao.Table.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/LocationPicker/LocationPicker.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java
deleted file mode 100644
index 696dc9810..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package fr.free.nrw.commons.LocationPicker;
-
-import android.app.Activity;
-import android.content.Intent;
-import com.mapbox.mapboxsdk.camera.CameraPosition;
-
-/**
- * Helper class for starting the activity
- */
-public final class LocationPicker {
-
- /**
- * Getting camera position from the intent using constants
- *
- * @param data intent
- * @return CameraPosition
- */
- public static CameraPosition getCameraPosition(final Intent data) {
- return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
- }
-
- public static class IntentBuilder {
-
- private final Intent intent;
-
- /**
- * Creates a new builder that creates an intent to launch the place picker activity.
- */
- public IntentBuilder() {
- intent = new Intent();
- }
-
- /**
- * Gets and puts location in intent
- * @param position CameraPosition
- * @return LocationPicker.IntentBuilder
- */
- public LocationPicker.IntentBuilder defaultLocation(
- final CameraPosition position) {
- intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position);
- return this;
- }
-
- /**
- * Gets and puts activity name in intent
- * @param activity activity key
- * @return LocationPicker.IntentBuilder
- */
- public LocationPicker.IntentBuilder activityKey(
- final String activity) {
- intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity);
- return this;
- }
-
- /**
- * Gets and sets the activity
- * @param activity Activity
- * @return Intent
- */
- public Intent build(final Activity activity) {
- intent.setClass(activity, LocationPickerActivity.class);
- return intent;
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java
deleted file mode 100644
index c8791e3c2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java
+++ /dev/null
@@ -1,450 +0,0 @@
-package fr.free.nrw.commons.LocationPicker;
-
-import static com.mapbox.mapboxsdk.style.layers.Property.NONE;
-import static com.mapbox.mapboxsdk.style.layers.Property.VISIBLE;
-import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconAllowOverlap;
-import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement;
-import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconImage;
-import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility;
-import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION;
-import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM;
-
-import android.content.Intent;
-import android.graphics.BitmapFactory;
-import android.os.Bundle;
-import android.text.Html;
-import android.text.method.LinkMovementMethod;
-import android.view.View;
-import android.view.Window;
-import android.view.animation.OvershootInterpolator;
-import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.TextView;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.widget.AppCompatTextView;
-import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.lifecycle.Observer;
-import androidx.lifecycle.ViewModelProvider;
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import com.mapbox.android.core.permissions.PermissionsManager;
-import com.mapbox.geojson.Point;
-import com.mapbox.mapboxsdk.camera.CameraPosition;
-import com.mapbox.mapboxsdk.camera.CameraPosition.Builder;
-import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
-import com.mapbox.mapboxsdk.geometry.LatLng;
-import com.mapbox.mapboxsdk.location.LocationComponent;
-import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions;
-import com.mapbox.mapboxsdk.location.modes.CameraMode;
-import com.mapbox.mapboxsdk.location.modes.RenderMode;
-import com.mapbox.mapboxsdk.maps.MapView;
-import com.mapbox.mapboxsdk.maps.MapboxMap;
-import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraIdleListener;
-import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener;
-import com.mapbox.mapboxsdk.maps.OnMapReadyCallback;
-import com.mapbox.mapboxsdk.maps.Style;
-import com.mapbox.mapboxsdk.maps.UiSettings;
-import com.mapbox.mapboxsdk.style.layers.Layer;
-import com.mapbox.mapboxsdk.style.layers.SymbolLayer;
-import com.mapbox.mapboxsdk.style.sources.GeoJsonSource;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.theme.BaseActivity;
-import javax.inject.Inject;
-import javax.inject.Named;
-import org.jetbrains.annotations.NotNull;
-import timber.log.Timber;
-
-/**
- * Helps to pick location and return the result with an intent
- */
-public class LocationPickerActivity extends BaseActivity implements OnMapReadyCallback,
- OnCameraMoveStartedListener, OnCameraIdleListener, Observer {
-
- /**
- * DROPPED_MARKER_LAYER_ID : id for layer
- */
- private static final String DROPPED_MARKER_LAYER_ID = "DROPPED_MARKER_LAYER_ID";
- /**
- * cameraPosition : position of picker
- */
- private CameraPosition cameraPosition;
- /**
- * markerImage : picker image
- */
- private ImageView markerImage;
- /**
- * mapboxMap : map
- */
- private MapboxMap mapboxMap;
- /**
- * mapView : view of the map
- */
- private MapView mapView;
- /**
- * tvAttribution : credit
- */
- private AppCompatTextView tvAttribution;
- /**
- * activity : activity key
- */
- private String activity;
- /**
- * modifyLocationButton : button for start editing location
- */
- Button modifyLocationButton;
- /**
- * showInMapButton : button for showing in map
- */
- TextView showInMapButton;
- /**
- * placeSelectedButton : fab for selecting location
- */
- FloatingActionButton placeSelectedButton;
- /**
- * droppedMarkerLayer : Layer for static screen
- */
- private Layer droppedMarkerLayer;
- /**
- * shadow : imageview of shadow
- */
- private ImageView shadow;
- /**
- * largeToolbarText : textView of shadow
- */
- private TextView largeToolbarText;
- /**
- * smallToolbarText : textView of shadow
- */
- private TextView smallToolbarText;
- /**
- * applicationKvStore : for storing values
- */
- @Inject
- @Named("default_preferences")
- public
- JsonKvStore applicationKvStore;
-
- @Override
- protected void onCreate(@Nullable final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
- final ActionBar actionBar = getSupportActionBar();
- if (actionBar != null) {
- actionBar.hide();
- }
- setContentView(R.layout.activity_location_picker);
-
- if (savedInstanceState == null) {
- cameraPosition = getIntent()
- .getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
- activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY);
- }
-
- final LocationPickerViewModel viewModel = new ViewModelProvider(this)
- .get(LocationPickerViewModel.class);
- viewModel.getResult().observe(this, this);
-
- bindViews();
- addBackButtonListener();
- addPlaceSelectedButton();
- addCredits();
- getToolbarUI();
-
- if (activity.equals("UploadActivity")) {
- placeSelectedButton.setVisibility(View.GONE);
- modifyLocationButton.setVisibility(View.VISIBLE);
- showInMapButton.setVisibility(View.VISIBLE);
- largeToolbarText.setText(getResources().getString(R.string.image_location));
- smallToolbarText.setText(getResources().
- getString(R.string.check_whether_location_is_correct));
- }
-
- mapView.onCreate(savedInstanceState);
- mapView.getMapAsync(this);
- }
-
- /**
- * For showing credits
- */
- private void addCredits() {
- tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
- tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
- }
-
- /**
- * Clicking back button destroy locationPickerActivity
- */
- private void addBackButtonListener() {
- final ImageView backButton = findViewById(R.id.mapbox_place_picker_toolbar_back_button);
- backButton.setOnClickListener(view -> finish());
- }
-
- /**
- * Binds mapView and location picker icon
- */
- private void bindViews() {
- mapView = findViewById(R.id.map_view);
- markerImage = findViewById(R.id.location_picker_image_view_marker);
- tvAttribution = findViewById(R.id.tv_attribution);
- modifyLocationButton = findViewById(R.id.modify_location);
- showInMapButton = findViewById(R.id.show_in_map);
- showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase());
- shadow = findViewById(R.id.location_picker_image_view_shadow);
- }
-
- /**
- * Binds the listeners
- */
- private void bindListeners() {
- mapboxMap.addOnCameraMoveStartedListener(
- this);
- mapboxMap.addOnCameraIdleListener(
- this);
- }
-
- /**
- * Gets toolbar color
- */
- private void getToolbarUI() {
- final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar);
- largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view);
- smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view);
- toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor));
- }
-
- /**
- * Takes action when map is ready to show
- * @param mapboxMap map
- */
- @Override
- public void onMapReady(final MapboxMap mapboxMap) {
- this.mapboxMap = mapboxMap;
- mapboxMap.setStyle(Style.MAPBOX_STREETS, this::onStyleLoaded);
- }
-
- /**
- * Initializes dropped marker and layer
- * Handles camera position based on options
- * Enables location components
- *
- * @param style style
- */
- private void onStyleLoaded(final Style style) {
- if (modifyLocationButton.getVisibility() == View.VISIBLE) {
- initDroppedMarker(style);
- adjustCameraBasedOnOptions();
- enableLocationComponent(style);
- if (style.getLayer(DROPPED_MARKER_LAYER_ID) != null) {
- final GeoJsonSource source = style.getSourceAs("dropped-marker-source-id");
- if (source != null) {
- source.setGeoJson(Point.fromLngLat(cameraPosition.target.getLongitude(),
- cameraPosition.target.getLatitude()));
- }
- droppedMarkerLayer = style.getLayer(DROPPED_MARKER_LAYER_ID);
- if (droppedMarkerLayer != null) {
- droppedMarkerLayer.setProperties(visibility(VISIBLE));
- markerImage.setVisibility(View.GONE);
- shadow.setVisibility(View.GONE);
- }
- }
- } else {
- adjustCameraBasedOnOptions();
- enableLocationComponent(style);
- bindListeners();
- }
-
- modifyLocationButton.setOnClickListener(v -> onClickModifyLocation());
- showInMapButton.setOnClickListener(v -> showInMap());
- }
-
- /**
- * Handles onclick event of modifyLocationButton
- */
- private void onClickModifyLocation() {
- placeSelectedButton.setVisibility(View.VISIBLE);
- modifyLocationButton.setVisibility(View.GONE);
- showInMapButton.setVisibility(View.GONE);
- droppedMarkerLayer.setProperties(visibility(NONE));
- markerImage.setVisibility(View.VISIBLE);
- shadow.setVisibility(View.VISIBLE);
- largeToolbarText.setText(getResources().getString(R.string.choose_a_location));
- smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust));
- bindListeners();
- }
-
- /**
- * Show the location in map app
- */
- public void showInMap(){
- Utils.handleGeoCoordinates(this,
- new fr.free.nrw.commons.location.LatLng(cameraPosition.target.getLatitude(),
- cameraPosition.target.getLongitude(), 0.0f));
- }
-
- /**
- * Initialize Dropped Marker and layer without showing
- * @param loadedMapStyle style
- */
- private void initDroppedMarker(@NonNull final Style loadedMapStyle) {
- // Add the marker image to map
- loadedMapStyle.addImage("dropped-icon-image", BitmapFactory.decodeResource(
- getResources(), R.drawable.map_default_map_marker));
- loadedMapStyle.addSource(new GeoJsonSource("dropped-marker-source-id"));
- loadedMapStyle.addLayer(new SymbolLayer(DROPPED_MARKER_LAYER_ID,
- "dropped-marker-source-id").withProperties(
- iconImage("dropped-icon-image"),
- visibility(NONE),
- iconAllowOverlap(true),
- iconIgnorePlacement(true)
- ));
- }
-
- /**
- * move the location to the current media coordinates
- */
- private void adjustCameraBasedOnOptions() {
- mapboxMap.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition));
- }
-
- /**
- * Enables location components
- * @param loadedMapStyle Style
- */
- @SuppressWarnings( {"MissingPermission"})
- private void enableLocationComponent(@NonNull final Style loadedMapStyle) {
- final UiSettings uiSettings = mapboxMap.getUiSettings();
- uiSettings.setAttributionEnabled(false);
-
- // Check if permissions are enabled and if not request
- if (PermissionsManager.areLocationPermissionsGranted(this)) {
-
- // Get an instance of the component
- final LocationComponent locationComponent = mapboxMap.getLocationComponent();
-
- // Activate with options
- locationComponent.activateLocationComponent(
- LocationComponentActivationOptions.builder(this, loadedMapStyle).build());
-
- // Enable to make component visible
- locationComponent.setLocationComponentEnabled(true);
-
- // Set the component's camera mode
- locationComponent.setCameraMode(CameraMode.NONE);
-
- // Set the component's render mode
- locationComponent.setRenderMode(RenderMode.NORMAL);
-
- }
- }
-
- /**
- * Acts on camera moving
- * @param reason int
- */
- @Override
- public void onCameraMoveStarted(final int reason) {
- Timber.v("Map camera has begun moving.");
- if (markerImage.getTranslationY() == 0) {
- markerImage.animate().translationY(-75)
- .setInterpolator(new OvershootInterpolator()).setDuration(250).start();
- }
- }
-
- /**
- * Acts on camera idle
- */
- @Override
- public void onCameraIdle() {
- Timber.v("Map camera is now idling.");
- markerImage.animate().translationY(0)
- .setInterpolator(new OvershootInterpolator()).setDuration(250).start();
- }
-
- /**
- * Takes action on camera position
- * @param position position of picker
- */
- @Override
- public void onChanged(@Nullable CameraPosition position) {
- if (position == null) {
- position = new Builder()
- .target(new LatLng(mapboxMap.getCameraPosition().target.getLatitude(),
- mapboxMap.getCameraPosition().target.getLongitude()))
- .zoom(16).build();
- }
- cameraPosition = position;
- }
-
- /**
- * Select the preferable location
- */
- private void addPlaceSelectedButton() {
- placeSelectedButton = findViewById(R.id.location_chosen_button);
- placeSelectedButton.setOnClickListener(view -> placeSelected());
- }
-
- /**
- * Return the intent with required data
- */
- void placeSelected() {
- if (activity.equals("NoLocationUploadActivity")) {
- applicationKvStore.putString(LAST_LOCATION,
- mapboxMap.getCameraPosition().target.getLatitude()
- + ","
- + mapboxMap.getCameraPosition().target.getLongitude());
- applicationKvStore.putString(LAST_ZOOM, mapboxMap.getCameraPosition().zoom + "");
- }
- final Intent returningIntent = new Intent();
- returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
- mapboxMap.getCameraPosition());
- setResult(AppCompatActivity.RESULT_OK, returningIntent);
- finish();
- }
-
- @Override
- protected void onStart() {
- super.onStart();
- mapView.onStart();
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- mapView.onResume();
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- mapView.onPause();
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- mapView.onStop();
- }
-
- @Override
- protected void onSaveInstanceState(final @NotNull Bundle outState) {
- super.onSaveInstanceState(outState);
- mapView.onSaveInstanceState(outState);
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- mapView.onDestroy();
- }
-
- @Override
- public void onLowMemory() {
- super.onLowMemory();
- mapView.onLowMemory();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java
deleted file mode 100644
index eb27e496c..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package fr.free.nrw.commons.LocationPicker;
-
-/**
- * Constants need for location picking
- */
-public final class LocationPickerConstants {
-
- public static final String ACTIVITY_KEY
- = "location.picker.activity";
-
- public static final String MAP_CAMERA_POSITION
- = "location.picker.cameraPosition";
-
-
- private LocationPickerConstants() {
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java
deleted file mode 100644
index 79019a5a4..000000000
--- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package fr.free.nrw.commons.LocationPicker;
-
-import android.app.Application;
-import androidx.annotation.NonNull;
-import androidx.lifecycle.AndroidViewModel;
-import androidx.lifecycle.MutableLiveData;
-import com.mapbox.mapboxsdk.camera.CameraPosition;
-import org.jetbrains.annotations.NotNull;
-import retrofit2.Call;
-import retrofit2.Callback;
-import retrofit2.Response;
-import timber.log.Timber;
-
-/**
- * Observes live camera position data
- */
-public class LocationPickerViewModel extends AndroidViewModel implements Callback {
-
- /**
- * Wrapping CameraPosition with MutableLiveData
- */
- private final MutableLiveData result = new MutableLiveData<>();
-
- /**
- * Constructor for this class
- *
- * @param application Application
- */
- public LocationPickerViewModel(@NonNull final Application application) {
- super(application);
- }
-
- /**
- * Responses on camera position changing
- *
- * @param call Call
- * @param response Response
- */
- @Override
- public void onResponse(final @NotNull Call call,
- final Response response) {
- if (response.body() == null) {
- result.setValue(null);
- return;
- }
- result.setValue(response.body());
- }
-
- @Override
- public void onFailure(final @NotNull Call call, final @NotNull Throwable t) {
- Timber.e(t);
- }
-
- /**
- * Gets live CameraPosition
- *
- * @return MutableLiveData
- */
- public MutableLiveData getResult() {
- return result;
- }
-
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/MapController.java b/app/src/main/java/fr/free/nrw/commons/MapController.java
new file mode 100644
index 000000000..72005fe83
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/MapController.java
@@ -0,0 +1,30 @@
+package fr.free.nrw.commons;
+
+import fr.free.nrw.commons.location.LatLng;
+import fr.free.nrw.commons.nearby.Place;
+import java.util.List;
+
+public abstract class MapController {
+
+ /**
+ * We pass this variable as a group of placeList and boundaryCoordinates
+ */
+ public class NearbyPlacesInfo {
+ public List placeList; // List of nearby places
+ public LatLng[] boundaryCoordinates; // Corners of nearby area
+ public LatLng currentLatLng; // Current location when this places are populated
+ public LatLng searchLatLng; // Search location for finding this places
+ public List mediaList; // Search location for finding this places
+ }
+
+ /**
+ * We pass this variable as a group of placeList and boundaryCoordinates
+ */
+ public class ExplorePlacesInfo {
+ public List explorePlaceList; // List of nearby places
+ public LatLng[] boundaryCoordinates; // Corners of nearby area
+ public LatLng currentLatLng; // Current location when this places are populated
+ public LatLng searchLatLng; // Search location for finding this places
+ public List mediaList; // Search location for finding this places
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt
index af78ae6dd..025302cfd 100644
--- a/app/src/main/java/fr/free/nrw/commons/Media.kt
+++ b/app/src/main/java/fr/free/nrw/commons/Media.kt
@@ -2,10 +2,12 @@ package fr.free.nrw.commons
import android.os.Parcelable
import fr.free.nrw.commons.location.LatLng
-import kotlinx.android.parcel.Parcelize
-import org.wikipedia.dataclient.mwapi.MwQueryPage
-import org.wikipedia.page.PageTitle
-import java.util.*
+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(
@@ -15,7 +17,6 @@ class Media constructor(
*/
var pageId: String = UUID.randomUUID().toString(),
var thumbUrl: String? = null,
-
/**
* Gets image URL
* @return Image URL
@@ -27,16 +28,11 @@ class Media constructor(
*/
var filename: String? = null,
/**
- * Gets the file description.
+ * Gets or sets the file description.
* @return file description as a string
- */
- // monolingual description on input...
- /**
- * Sets the file description.
* @param fallbackDescription the new description of the file
*/
var fallbackDescription: String? = null,
-
/**
* Gets the upload date of the file.
* Can be null.
@@ -44,28 +40,19 @@ class Media constructor(
*/
var dateUploaded: Date? = null,
/**
- * Gets the license name of the file.
+ * Gets or sets the license name of the file.
* @return license as a String
- */
- /**
- * Sets the license name of the file.
- *
* @param license license name as a String
*/
var license: String? = null,
var licenseUrl: String? = null,
/**
- * Gets the name of the creator of the file.
+ * Gets or sets the name of the creator of the file.
* @return author name as a String
- */
- /**
- * Sets the author name of the file.
* @param author creator name as a string
*/
var author: String? = null,
-
- var user:String?=null,
-
+ var user: String? = null,
/**
* Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings
@@ -84,23 +71,23 @@ class Media constructor(
* Stores the mapping of category title to hidden attribute
* Example: "Mountains" => false, "CC-BY-SA-2.0" => true
*/
- var categoriesHiddenStatus: Map = emptyMap()
+ var categoriesHiddenStatus: Map = emptyMap(),
) : Parcelable {
-
constructor(
captions: Map,
categories: List?,
filename: String?,
fallbackDescription: String?,
- author: String?, user:String?
+ author: String?,
+ user: String?,
) : this(
filename = filename,
fallbackDescription = fallbackDescription,
dateUploaded = Date(),
author = author,
- user=user,
+ user = user,
categories = categories,
- captions = captions
+ captions = captions,
)
/**
@@ -109,10 +96,11 @@ class Media constructor(
*/
val displayTitle: String
get() =
- if (filename != null)
+ if (filename != null) {
pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
- else
+ } else {
""
+ }
/**
* Gets file page title
@@ -128,17 +116,21 @@ class Media constructor(
get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
val mostRelevantCaption: String
- get() = captions[Locale.getDefault().language]
- ?: captions.values.firstOrNull()
- ?: displayTitle
+ 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
+ get() = field // getter
+ set(value) {
+ field = value
+ } // setter
}
diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
index 0b42137e7..2ff54959d 100644
--- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
+++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt
@@ -1,9 +1,9 @@
package fr.free.nrw.commons
import androidx.core.text.HtmlCompat
-import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import fr.free.nrw.commons.media.IdAndCaptions
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
@@ -17,42 +17,46 @@ import javax.inject.Singleton
* to the media and may change due to editing.
*/
@Singleton
-class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) {
+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) -> IdAndCaptions(key, value) } }
+ .onErrorReturn { emptyList() }
- 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) -> IdAndCaptions(key, value) } }
- .onErrorReturn { emptyList() }
+ fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
- 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 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 refresh(media: Media): Single {
- return Single.ambArray(
- mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId)
- .onErrorResumeNext { Single.never() },
- mediaClient.getMedia(media.filename)
- .onErrorResumeNext { Single.never() }
- )
+ fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
+ /**
+ * Fetches wikitext from mediaClient
+ */
+ fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title)
}
-
- fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title);
-
- /**
- * Fetches wikitext from mediaClient
- */
- fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title);
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java
index ab00d1721..a3cef1172 100644
--- a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java
+++ b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java
@@ -1,9 +1,9 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar;
import java.io.File;
import java.io.IOException;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -15,28 +15,26 @@ import okhttp3.Response;
import okhttp3.ResponseBody;
import okhttp3.logging.HttpLoggingInterceptor;
import okhttp3.logging.HttpLoggingInterceptor.Level;
-import org.wikipedia.dataclient.SharedPreferenceCookieManager;
-import org.wikipedia.dataclient.okhttp.HttpStatusException;
import timber.log.Timber;
public final class OkHttpConnectionFactory {
private static final String CACHE_DIR_NAME = "okhttp-cache";
private static final long NET_CACHE_SIZE = 64 * 1024 * 1024;
- @NonNull private static final Cache NET_CACHE = new Cache(new File(CommonsApplication.getInstance().getCacheDir(),
- CACHE_DIR_NAME), NET_CACHE_SIZE);
- @NonNull
- private static final OkHttpClient CLIENT = createClient();
+ public static OkHttpClient CLIENT;
- @NonNull public static OkHttpClient getClient() {
+ @NonNull public static OkHttpClient getClient(final CommonsCookieJar cookieJar) {
+ if (CLIENT == null) {
+ CLIENT = createClient(cookieJar);
+ }
return CLIENT;
}
@NonNull
- private static OkHttpClient createClient() {
+ private static OkHttpClient createClient(final CommonsCookieJar cookieJar) {
return new OkHttpClient.Builder()
- .cookieJar(SharedPreferenceCookieManager.getInstance())
- .cache(NET_CACHE)
+ .cookieJar(cookieJar)
+ .cache((CommonsApplication.getInstance()!=null) ? new Cache(new File(CommonsApplication.getInstance().getCacheDir(), CACHE_DIR_NAME), NET_CACHE_SIZE) : null)
.connectTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
@@ -69,6 +67,8 @@ public final class OkHttpConnectionFactory {
}
public static class UnsuccessfulResponseInterceptor implements Interceptor {
+ private static final String SUPPRESS_ERROR_LOG = "x-commons-suppress-error-log";
+ public static final String SUPPRESS_ERROR_LOG_HEADER = SUPPRESS_ERROR_LOG+": true";
private static final List DO_NOT_INTERCEPT = Collections.singletonList(
"api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1");
@@ -77,7 +77,16 @@ public final class OkHttpConnectionFactory {
@Override
@NonNull
public Response intercept(@NonNull final Chain chain) throws IOException {
- final Response rsp = chain.proceed(chain.request());
+ final Request 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.
+ final boolean suppressErrors = rq.headers().names().contains(SUPPRESS_ERROR_LOG);
+ final Request request = rq.newBuilder()
+ .removeHeader(SUPPRESS_ERROR_LOG)
+ .build();
+
+ final Response rsp = chain.proceed(request);
// Do not intercept certain requests and let the caller handle the errors
if(isExcludedUrl(chain.request())) {
@@ -91,7 +100,12 @@ public final class OkHttpConnectionFactory {
}
}
} catch (final IOException e) {
- Timber.e(e);
+ // 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);
+ }
}
return rsp;
}
@@ -111,4 +125,30 @@ public final class OkHttpConnectionFactory {
private OkHttpConnectionFactory() {
}
+
+ public static class HttpStatusException extends IOException {
+ private final int code;
+ private final String url;
+ public HttpStatusException(@NonNull Response rsp) {
+ this.code = rsp.code();
+ this.url = rsp.request().url().uri().toString();
+ try {
+ if (rsp.body() != null && rsp.body().contentType() != null
+ && rsp.body().contentType().toString().contains("json")) {
+ }
+ } catch (Exception e) {
+ // Log?
+ }
+ }
+
+ public int code() {
+ return code;
+ }
+
+ @Override
+ public String getMessage() {
+ String str = "Code: " + code + ", URL: " + url;
+ return str;
+ }
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/Urls.kt b/app/src/main/java/fr/free/nrw/commons/Urls.kt
index 88470bc69..3eb7ee243 100644
--- a/app/src/main/java/fr/free/nrw/commons/Urls.kt
+++ b/app/src/main/java/fr/free/nrw/commons/Urls.kt
@@ -3,12 +3,16 @@ package fr.free.nrw.commons
internal object Urls {
const val NEW_ISSUE_URL = "https://github.com/commons-app/apps-android-commons/issues"
const val GITHUB_REPO_URL = "https://github.com/commons-app/apps-android-commons"
+ const val GITHUB_PACKAGE_NAME = "com.github.android"
const val WEBSITE_URL = "https://commons-app.github.io"
const val CREDITS_URL = "https://github.com/commons-app/apps-android-commons/blob/master/CREDITS"
+ const val USER_GUIDE_URL = "https://commons-app.github.io/docs.html"
const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md"
const val PLAY_STORE_PREFIX = "market://details?id="
const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id="
- const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language="
+ const val TRANSLATE_WIKI_URL =
+ "https://translatewiki.net/w/i.php?title=Special:Translate" +
+ "&group=commons-android-strings&filter=%21translated&action=translate&language="
const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985"
const val FACEBOOK_APP_URL = "fb://page/1921335171459985"
const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"
diff --git a/app/src/main/java/fr/free/nrw/commons/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java
index 70c032d80..cd9c6eed5 100644
--- a/app/src/main/java/fr/free/nrw/commons/Utils.java
+++ b/app/src/main/java/fr/free/nrw/commons/Utils.java
@@ -10,17 +10,16 @@ import android.text.SpannableString;
import android.text.style.UnderlineSpan;
import android.view.View;
import android.widget.TextView;
-import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.content.ContextCompat;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
+import java.util.Calendar;
import java.util.Date;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.page.PageTitle;
+import fr.free.nrw.commons.wikidata.model.WikiSite;
+import fr.free.nrw.commons.wikidata.model.page.PageTitle;
import java.util.Locale;
import java.util.regex.Pattern;
@@ -30,9 +29,6 @@ import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.utils.ViewUtil;
import timber.log.Timber;
-import static android.widget.Toast.LENGTH_SHORT;
-import static fr.free.nrw.commons.campaigns.CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE;
-
public class Utils {
public static PageTitle getPageTitle(@NonNull String title) {
@@ -136,12 +132,6 @@ public class Utils {
*/
public static void handleWebUrl(Context context, Uri url) {
Timber.d("Launching web url %s", url.toString());
- Intent browserIntent = new Intent(Intent.ACTION_VIEW, url);
- if (browserIntent.resolveActivity(context.getPackageManager()) == null) {
- Toast toast = Toast.makeText(context, context.getString(R.string.no_web_browser), LENGTH_SHORT);
- toast.show();
- return;
- }
final CustomTabColorSchemeParams color = new CustomTabColorSchemeParams.Builder()
.setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor))
@@ -243,4 +233,18 @@ public class Utils {
return "30 Sep";
}
+ /***
+ * Function to get the current WLM year
+ * It increments at the start of September in line with the other WLM functions
+ * (No consideration of locales for now)
+ * @param calendar
+ * @return
+ */
+ public static int getWikiLovesMonumentsYear(Calendar calendar) {
+ int year = calendar.get(Calendar.YEAR);
+ if (calendar.get(Calendar.MONTH) < Calendar.SEPTEMBER) {
+ year -= 1;
+ }
+ return year;
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
index b5546516e..c8cedfef1 100644
--- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
@@ -1,35 +1,25 @@
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 androidx.viewpager.widget.ViewPager;
-
-import com.viewpagerindicator.CirclePageIndicator;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
+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.ConfigUtils;
-import android.app.AlertDialog;
-import android.widget.Button;
public class WelcomeActivity extends BaseActivity {
- @BindView(R.id.welcomePager)
- ViewPager pager;
- @BindView(R.id.welcomePagerIndicator)
- CirclePageIndicator indicator;
+ private ActivityWelcomeBinding binding;
+ private PopupForCopyrightBinding copyrightBinding;
- private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
+ private final WelcomePagerAdapter adapter = new WelcomePagerAdapter();
private boolean isQuiz;
private AlertDialog.Builder dialogBuilder;
private AlertDialog dialog;
- Button okButton;
/**
* Initialises exiting fields and dependencies
@@ -37,12 +27,14 @@ public class WelcomeActivity extends BaseActivity {
* @param savedInstanceState WelcomeActivity bundled data
*/
@Override
- public void onCreate(Bundle savedInstanceState) {
+ public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_welcome);
+ binding = ActivityWelcomeBinding.inflate(getLayoutInflater());
+ final View view = binding.getRoot();
+ setContentView(view);
if (getIntent() != null) {
- Bundle bundle = getIntent().getExtras();
+ final Bundle bundle = getIntent().getExtras();
if (bundle != null) {
isQuiz = bundle.getBoolean("isQuiz");
}
@@ -52,22 +44,24 @@ public class WelcomeActivity extends BaseActivity {
// Enable skip button if beta flavor
if (ConfigUtils.isBetaFlavour()) {
- findViewById(R.id.finishTutorialButton).setVisibility(View.VISIBLE);
+ binding.finishTutorialButton.setVisibility(View.VISIBLE);
dialogBuilder = new AlertDialog.Builder(this);
- final View contactPopupView = getLayoutInflater().inflate(R.layout.popup_for_copyright,null);
+ copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater());
+ final View contactPopupView = copyrightBinding.getRoot();
dialogBuilder.setView(contactPopupView);
+ dialogBuilder.setCancelable(false);
dialog = dialogBuilder.create();
dialog.show();
- okButton = dialog.findViewById(R.id.button_ok);
- okButton.setOnClickListener(view -> dialog.dismiss());
+ copyrightBinding.buttonOk.setOnClickListener(v -> dialog.dismiss());
}
- ButterKnife.bind(this);
+ binding.welcomePager.setAdapter(adapter);
+ binding.welcomePagerIndicator.setViewPager(binding.welcomePager);
+
+ binding.finishTutorialButton.setOnClickListener(v -> finishTutorial());
- pager.setAdapter(adapter);
- indicator.setViewPager(pager);
}
/**
@@ -76,7 +70,7 @@ public class WelcomeActivity extends BaseActivity {
@Override
public void onDestroy() {
if (isQuiz) {
- Intent i = new Intent(WelcomeActivity.this, QuizActivity.class);
+ final Intent i = new Intent(this, QuizActivity.class);
startActivity(i);
}
super.onDestroy();
@@ -87,8 +81,8 @@ public class WelcomeActivity extends BaseActivity {
*
* @param context Activity context
*/
- public static void startYourself(Context context) {
- Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
+ public static void startYourself(final Context context) {
+ final Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
context.startActivity(welcomeIntent);
}
@@ -97,8 +91,8 @@ public class WelcomeActivity extends BaseActivity {
*/
@Override
public void onBackPressed() {
- if (pager.getCurrentItem() != 0) {
- pager.setCurrentItem(pager.getCurrentItem() - 1, true);
+ if (binding.welcomePager.getCurrentItem() != 0) {
+ binding.welcomePager.setCurrentItem(binding.welcomePager.getCurrentItem() - 1, true);
} else {
if (defaultKvStore.getBoolean("firstrun", true)) {
finishAffinity();
@@ -108,7 +102,6 @@ public class WelcomeActivity extends BaseActivity {
}
}
- @OnClick(R.id.finishTutorialButton)
public void finishTutorial() {
defaultKvStore.putBoolean("firstrun", false);
finish();
diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
index 7017c028b..8fd3fc704 100644
--- a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
+++ b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.java
@@ -1,7 +1,6 @@
package fr.free.nrw.commons;
import android.net.Uri;
-import android.text.Html;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
new file mode 100644
index 000000000..f49dd7705
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt
@@ -0,0 +1,18 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.wikidata.mwapi.MwResponse
+
+/**
+ * Response of the Thanks API.
+ * Context:
+ * The Commons Android app lets you thank other contributors who have uploaded a great picture.
+ * See https://www.mediawiki.org/wiki/Extension:Thanks
+ */
+class MwThankPostResponse : MwResponse() {
+ var result: Result? = null
+
+ inner class Result {
+ var success: Int? = null
+ var recipient: String? = null
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
index cf7b9865c..a3d6de257 100644
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
+++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt
@@ -1,8 +1,9 @@
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
-import org.wikipedia.csrf.CsrfTokenClient
/**
* This class acts as a Client to facilitate wiki page editing
@@ -13,9 +14,8 @@ import org.wikipedia.csrf.CsrfTokenClient
*/
class PageEditClient(
private val csrfTokenClient: CsrfTokenClient,
- private val pageEditInterface: PageEditInterface
+ private val pageEditInterface: PageEditInterface,
) {
-
/**
* Replace the content of a wiki page
* @param pageTitle Title of the page to edit
@@ -23,14 +23,60 @@ class PageEditClient(
* @param summary Edit summary
* @return whether the edit was successful
*/
- fun edit(pageTitle: String, text: String, summary: String): Observable {
- return try {
- pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.tokenBlocking)
- .map { editResponse -> editResponse.edit()!!.editSucceeded() }
+ 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) {
- Observable.just(false)
+ 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
@@ -39,14 +85,22 @@ class PageEditClient(
* @param summary Edit summary
* @return whether the edit was successful
*/
- fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable {
- return try {
- pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.tokenBlocking)
+ 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) {
- Observable.just(false)
+ if (throwable is InvalidLoginTokenException) {
+ throw throwable
+ } else {
+ Observable.just(false)
+ }
}
- }
/**
* Prepend text to the beginning of a wiki page
@@ -55,14 +109,48 @@ class PageEditClient(
* @param summary Edit summary
* @return whether the edit was successful
*/
- fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable {
- return try {
- pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.tokenBlocking)
+ 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) {
- Observable.just(false)
+ if (throwable is InvalidLoginTokenException) {
+ throw throwable
+ } else {
+ Observable.just(false)
+ }
}
- }
/**
* Set new labels to Wikibase server of commons
@@ -72,24 +160,42 @@ class PageEditClient(
* @param value label
* @return 1 when the edit was successful
*/
- fun setCaptions(summary: String, title: String,
- language: String, value: String) : Observable{
- return try {
- pageEditInterface.postCaptions(summary, title, language,
- value, csrfTokenClient.tokenBlocking).map { it.success }
+ 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) {
- Observable.just(0)
+ 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 {
- return pageEditInterface.getWikiText(title).map {
- it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content()
+ fun getCurrentWikiText(title: String): Single =
+ pageEditInterface.getWikiText(title).map {
+ it
+ .query()
+ ?.pages()
+ ?.get(0)
+ ?.revisions()
+ ?.get(0)
+ ?.content()
}
- }
-}
\ No newline at end of file
+}
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
index 99f8b7d47..db43bb620 100644
--- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt
+++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt
@@ -1,12 +1,17 @@
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 org.wikipedia.dataclient.Service
-import org.wikipedia.dataclient.mwapi.MwQueryResponse
-import org.wikipedia.edit.Edit
-import org.wikipedia.wikidata.Entities
-import retrofit2.http.*
+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
@@ -27,13 +32,40 @@ interface PageEditInterface {
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
- @POST(Service.MW_API_PREFIX + "action=edit")
+ @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
+ @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
/**
@@ -47,12 +79,12 @@ interface PageEditInterface {
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
- @POST(Service.MW_API_PREFIX + "action=edit")
+ @POST(MW_API_PREFIX + "action=edit")
fun postAppendEdit(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("appendtext") appendText: String,
- @Field("token") token: String
+ @Field("token") token: String,
): Observable
/**
@@ -66,24 +98,34 @@ interface PageEditInterface {
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
- @POST(Service.MW_API_PREFIX + "action=edit")
+ @POST(MW_API_PREFIX + "action=edit")
fun postPrependEdit(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("prependtext") prependText: String,
- @Field("token") token: String
+ @Field("token") token: String,
): Observable
-
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
- @POST(Service.MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
+ @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
+ @Field("token") token: String,
): Observable
/**
@@ -91,11 +133,8 @@ interface PageEditInterface {
* @param titles : Name of the file
* @return Single
*/
- @GET(
- Service.MW_API_PREFIX +
- "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles="
- )
+ @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=")
fun getWikiText(
- @Query("titles") title: String
+ @Query("titles") title: String,
): Single
-}
\ No newline at end of file
+}
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
index d0ff6629c..1dcf93edf 100644
--- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt
+++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt
@@ -1,11 +1,10 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.CommonsApplication
-import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF
+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 org.wikipedia.csrf.CsrfTokenClient
-import org.wikipedia.dataclient.Service
-import org.wikipedia.dataclient.mwapi.MwPostResponse
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@@ -15,22 +14,33 @@ import javax.inject.Singleton
* 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,
- @param:Named("commons-service") private val service: Service
-) {
- /**
- * 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 {
- return try {
- service.thank(revisionId.toString(), null, csrfTokenClient.tokenBlocking, CommonsApplication.getInstance().userAgent)
- .map { mwThankPostResponse -> mwThankPostResponse.result.success== 1 }
- } catch (throwable: Throwable) {
- Observable.just(false)
- }
+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)
+ }
+ }
}
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt
new file mode 100644
index 000000000..62934d0f2
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt
@@ -0,0 +1,24 @@
+package fr.free.nrw.commons.actions
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import io.reactivex.Observable
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.POST
+
+/**
+ * Thanks API.
+ * Context:
+ * The Commons Android app lets you thank another contributor who has uploaded a great picture.
+ * See https://www.mediawiki.org/wiki/Extension:Thanks
+ */
+interface ThanksInterface {
+ @FormUrlEncoded
+ @POST(MW_API_PREFIX + "action=thank")
+ fun thank(
+ @Field("rev") rev: String?,
+ @Field("log") log: String?,
+ @Field("token") token: String,
+ @Field("source") source: String?,
+ ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt
new file mode 100644
index 000000000..284c84caf
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt
@@ -0,0 +1,181 @@
+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.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.R
+import timber.log.Timber
+
+/**
+ * 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() {
+ @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
+ }
+ 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 = {
+ // TODO Redirect the user to login screen like we do when the user logout's
+ 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)
+ }
+
+ }
+
+ webChromeClient = object : WebChromeClient() {
+ override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+ Timber.d("Console: ${message.message()} -- From line ${message.lineNumber()} of ${message.sourceId()}")
+ return true
+ }
+ }
+
+ loadUrl(url)
+ }
+ },
+ update = {
+ webView.value = it
+ }
+ )
+
+ }
+
+ companion object {
+ private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
+ private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"
+
+ /**
+ * Launch the WebViewActivity with the specified URL and success URL.
+ * @param context The context from which the activity is launched.
+ * @param url The initial URL to load in the WebView.
+ * @param successUrl The URL that triggers the WebView to close when matched.
+ */
+ fun showWebView(
+ context: Context,
+ url: String,
+ successUrl: String
+ ) {
+ val intent = Intent(
+ context,
+ SingleWebViewActivity::class.java
+ ).apply {
+ putExtra(VANISH_ACCOUNT_URL, url)
+ putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl)
+ }
+ context.startActivity(intent)
+ }
+ }
+}
+
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
deleted file mode 100644
index 53903769d..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.content.Context;
-
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-import timber.log.Timber;
-
-public class AccountUtil {
-
- public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
-
- public AccountUtil() {
- }
-
- /**
- * @return Account|null
- */
- @Nullable
- public static Account account(Context context) {
- try {
- Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- if (accounts.length > 0) {
- return accounts[0];
- }
- } catch (SecurityException e) {
- Timber.e(e);
- }
- return null;
- }
-
- @Nullable
- public static String getUserName(Context context) {
- Account account = account(context);
- return account == null ? null : account.name;
- }
-
- private static AccountManager accountManager(Context context) {
- return AccountManager.get(context);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
new file mode 100644
index 000000000..aa86cd0d8
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
@@ -0,0 +1,24 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import timber.log.Timber
+
+const val AUTH_TOKEN_TYPE: String = "CommonsAndroid"
+
+fun getUserName(context: Context): String? {
+ return account(context)?.name
+}
+
+@VisibleForTesting
+fun account(context: Context): Account? = try {
+ val accountManager = AccountManager.get(context)
+ val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
+ if (accounts.isNotEmpty()) accounts[0] else null
+} catch (e: SecurityException) {
+ Timber.e(e)
+ null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
deleted file mode 100644
index edb02ebdd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
+++ /dev/null
@@ -1,496 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AccountAuthenticatorActivity;
-import android.app.ProgressDialog;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.view.KeyEvent;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.inputmethod.InputMethodManager;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.TextView;
-
-import androidx.annotation.ColorRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.app.AppCompatDelegate;
-import androidx.core.app.NavUtils;
-import androidx.core.content.ContextCompat;
-
-import com.google.android.material.textfield.TextInputLayout;
-
-import fr.free.nrw.commons.utils.ActivityUtils;
-import java.util.Locale;
-import org.wikipedia.AppAdapter;
-import org.wikipedia.dataclient.ServiceFactory;
-import org.wikipedia.dataclient.WikiSite;
-import org.wikipedia.dataclient.mwapi.MwQueryResponse;
-import org.wikipedia.login.LoginClient;
-import org.wikipedia.login.LoginClient.LoginCallback;
-import org.wikipedia.login.LoginResult;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import butterknife.OnClick;
-import butterknife.OnEditorAction;
-import butterknife.OnFocusChange;
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.Utils;
-import fr.free.nrw.commons.WelcomeActivity;
-import fr.free.nrw.commons.contributions.MainActivity;
-import fr.free.nrw.commons.di.ApplicationlessInjection;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import fr.free.nrw.commons.utils.ConfigUtils;
-import fr.free.nrw.commons.utils.SystemThemeUtils;
-import fr.free.nrw.commons.utils.ViewUtil;
-import io.reactivex.disposables.CompositeDisposable;
-import retrofit2.Call;
-import retrofit2.Callback;
-import retrofit2.Response;
-import timber.log.Timber;
-
-import static android.view.KeyEvent.KEYCODE_ENTER;
-import static android.view.View.VISIBLE;
-import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
-import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_WIKI_SITE;
-
-public class LoginActivity extends AccountAuthenticatorActivity {
-
- @Inject
- SessionManager sessionManager;
-
- @Inject
- @Named(NAMED_COMMONS_WIKI_SITE)
- WikiSite commonsWikiSite;
-
- @Inject
- @Named("default_preferences")
- JsonKvStore applicationKvStore;
-
- @Inject
- LoginClient loginClient;
-
- @Inject
- SystemThemeUtils systemThemeUtils;
-
- @BindView(R.id.login_button)
- Button loginButton;
-
- @BindView(R.id.login_username)
- EditText usernameEdit;
-
- @BindView(R.id.login_password)
- EditText passwordEdit;
-
- @BindView(R.id.login_two_factor)
- EditText twoFactorEdit;
-
- @BindView(R.id.error_message_container)
- ViewGroup errorMessageContainer;
-
- @BindView(R.id.error_message)
- TextView errorMessage;
-
- @BindView(R.id.login_credentials)
- TextView loginCredentials;
-
- @BindView(R.id.two_factor_container)
- TextInputLayout twoFactorContainer;
-
- ProgressDialog progressDialog;
- private AppCompatDelegate delegate;
- private LoginTextWatcher textWatcher = new LoginTextWatcher();
- private CompositeDisposable compositeDisposable = new CompositeDisposable();
- private Call loginToken;
- final String saveProgressDailog="ProgressDailog_state";
- final String saveErrorMessage ="errorMessage";
- final String saveUsername="username";
- final String savePassword="password";
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- ApplicationlessInjection
- .getInstance(this.getApplicationContext())
- .getCommonsApplicationComponent()
- .inject(this);
-
- boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode();
- setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
- getDelegate().installViewFactory();
- getDelegate().onCreate(savedInstanceState);
-
- setContentView(R.layout.activity_login);
-
- ButterKnife.bind(this);
-
- usernameEdit.addTextChangedListener(textWatcher);
- passwordEdit.addTextChangedListener(textWatcher);
- twoFactorEdit.addTextChangedListener(textWatcher);
-
- if (ConfigUtils.isBetaFlavour()) {
- loginCredentials.setText(getString(R.string.login_credential));
- } else {
- loginCredentials.setVisibility(View.GONE);
- }
- }
-
- @OnFocusChange(R.id.login_password)
- void onPasswordFocusChanged(View view, boolean hasFocus) {
- if (!hasFocus) {
- ViewUtil.hideKeyboard(view);
- }
- }
-
- @OnEditorAction(R.id.login_password)
- boolean onEditorAction(int actionId, KeyEvent keyEvent) {
- if (loginButton.isEnabled()) {
- if (actionId == IME_ACTION_DONE) {
- performLogin();
- return true;
- } else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
- performLogin();
- return true;
- }
- }
- return false;
- }
-
-
- @OnClick(R.id.skip_login)
- void skipLogin() {
- new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
- .setMessage(R.string.skip_login_message)
- .setCancelable(false)
- .setPositiveButton(R.string.yes, (dialog, which) -> {
- dialog.cancel();
- performSkipLogin();
- })
- .setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel())
- .show();
- }
-
- @OnClick(R.id.forgot_password)
- void forgotPassword() {
- Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
- }
-
- @OnClick(R.id.about_privacy_policy)
- void onPrivacyPolicyClicked() {
- Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
- }
-
- @OnClick(R.id.sign_up_button)
- void signUp() {
- Intent intent = new Intent(this, SignupActivity.class);
- startActivity(intent);
- }
-
- @Override
- protected void onPostCreate(Bundle savedInstanceState) {
- super.onPostCreate(savedInstanceState);
- getDelegate().onPostCreate(savedInstanceState);
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- if (sessionManager.getCurrentAccount() != null
- && sessionManager.isUserLoggedIn()) {
- applicationKvStore.putBoolean("login_skipped", false);
- startMainActivity();
- }
-
- if (applicationKvStore.getBoolean("login_skipped", false)) {
- performSkipLogin();
- }
-
- }
-
- @Override
- protected void onDestroy() {
- compositeDisposable.clear();
- try {
- // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
- if (progressDialog != null && progressDialog.isShowing()) {
- progressDialog.dismiss();
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- usernameEdit.removeTextChangedListener(textWatcher);
- passwordEdit.removeTextChangedListener(textWatcher);
- twoFactorEdit.removeTextChangedListener(textWatcher);
- delegate.onDestroy();
- if(null!=loginClient) {
- loginClient.cancel();
- }
- super.onDestroy();
- }
-
- @OnClick(R.id.login_button)
- public void performLogin() {
- Timber.d("Login to start!");
- final String username = usernameEdit.getText().toString();
- final String rawUsername = usernameEdit.getText().toString().trim();
- final String password = passwordEdit.getText().toString();
- String twoFactorCode = twoFactorEdit.getText().toString();
-
- showLoggingProgressBar();
- doLogin(username, password, twoFactorCode);
- }
-
- private void doLogin(String username, String password, String twoFactorCode) {
- progressDialog.show();
- loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
- loginToken.enqueue(
- new Callback() {
- @Override
- public void onResponse(Call call,
- Response response) {
- loginClient.login(commonsWikiSite, username, password, null, twoFactorCode,
- response.body().query().loginToken(), Locale.getDefault().getLanguage(), new LoginCallback() {
- @Override
- public void success(@NonNull LoginResult result) {
- Timber.d("Login Success");
- onLoginSuccess(result);
- }
-
- @Override
- public void twoFactorPrompt(@NonNull Throwable caught,
- @Nullable String token) {
- Timber.d("Requesting 2FA prompt");
- hideProgress();
- askUserForTwoFactorAuth();
- }
-
- @Override
- public void passwordResetPrompt(@Nullable String token) {
- Timber.d("Showing password reset prompt");
- hideProgress();
- showPasswordResetPrompt();
- }
-
- @Override
- public void error(@NonNull Throwable caught) {
- Timber.e(caught);
- hideProgress();
- showMessageAndCancelDialog(caught.getLocalizedMessage());
- }
- });
- }
-
- @Override
- public void onFailure(Call call, Throwable t) {
- Timber.e(t);
- showMessageAndCancelDialog(t.getLocalizedMessage());
- }
- });
-
- }
-
- private void hideProgress() {
- progressDialog.dismiss();
- }
-
- private void showPasswordResetPrompt() {
- showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword));
- }
-
-
- /**
- * This function is called when user skips the login.
- * It redirects the user to Explore Activity.
- */
- private void performSkipLogin() {
- applicationKvStore.putBoolean("login_skipped", true);
- MainActivity.startYourself(this);
- finish();
- }
-
- private void showLoggingProgressBar() {
- progressDialog = new ProgressDialog(this);
- progressDialog.setIndeterminate(true);
- progressDialog.setTitle(getString(R.string.logging_in_title));
- progressDialog.setMessage(getString(R.string.logging_in_message));
- progressDialog.setCanceledOnTouchOutside(false);
- progressDialog.show();
- }
-
- private void onLoginSuccess(LoginResult loginResult) {
- if (!progressDialog.isShowing()) {
- // no longer attached to activity!
- return;
- }
- compositeDisposable.clear();
- sessionManager.setUserLoggedIn(true);
- AppAdapter.get().updateAccount(loginResult);
- progressDialog.dismiss();
- showSuccessAndDismissDialog();
- startMainActivity();
- }
-
- @Override
- protected void onStart() {
- super.onStart();
- delegate.onStart();
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- delegate.onStop();
- }
-
- @Override
- protected void onPostResume() {
- super.onPostResume();
- getDelegate().onPostResume();
- }
-
- @Override
- public void setContentView(View view, ViewGroup.LayoutParams params) {
- getDelegate().setContentView(view, params);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case android.R.id.home:
- NavUtils.navigateUpFromSameTask(this);
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- @Override
- @NonNull
- public MenuInflater getMenuInflater() {
- return getDelegate().getMenuInflater();
- }
-
- public void askUserForTwoFactorAuth() {
- progressDialog.dismiss();
- twoFactorContainer.setVisibility(VISIBLE);
- twoFactorEdit.setVisibility(VISIBLE);
- twoFactorEdit.requestFocus();
- InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
- imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
- showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
- }
-
- public void showMessageAndCancelDialog(@StringRes int resId) {
- showMessage(resId, R.color.secondaryDarkColor);
- if (progressDialog != null) {
- progressDialog.cancel();
- }
- }
-
- public void showMessageAndCancelDialog(String error) {
- showMessage(error, R.color.secondaryDarkColor);
- if (progressDialog != null) {
- progressDialog.cancel();
- }
- }
-
- public void showSuccessAndDismissDialog() {
- showMessage(R.string.login_success, R.color.primaryDarkColor);
- progressDialog.dismiss();
- }
-
- public void startMainActivity() {
- ActivityUtils.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
- finish();
- }
-
- private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
- errorMessage.setText(getString(resId));
- errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
- errorMessageContainer.setVisibility(VISIBLE);
- }
-
- private void showMessage(String message, @ColorRes int colorResId) {
- errorMessage.setText(message);
- errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
- errorMessageContainer.setVisibility(VISIBLE);
- }
-
- private AppCompatDelegate getDelegate() {
- if (delegate == null) {
- delegate = AppCompatDelegate.create(this, null);
- }
- return delegate;
- }
-
- private class LoginTextWatcher implements TextWatcher {
- @Override
- public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
- }
-
- @Override
- public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
- }
-
- @Override
- public void afterTextChanged(Editable editable) {
- boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
- && (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
- loginButton.setEnabled(enabled);
- }
- }
-
- public static void startYourself(Context context) {
- Intent intent = new Intent(context, LoginActivity.class);
- context.startActivity(intent);
- }
-
- @Override
- protected void onSaveInstanceState(Bundle outState) {
- // if progressDialog is visible during the configuration change then store state as true else false so that
- // we maintain visibility of progressDailog after configuration change
- if(progressDialog!=null&&progressDialog.isShowing()) {
- outState.putBoolean(saveProgressDailog,true);
- } else {
- outState.putBoolean(saveProgressDailog,false);
- }
- outState.putString(saveErrorMessage,errorMessage.getText().toString()); //Save the errorMessage
- outState.putString(saveUsername,getUsername()); // Save the username
- outState.putString(savePassword,getPassword()); // Save the password
- }
- private String getUsername() {
- return usernameEdit.getText().toString();
- }
- private String getPassword(){
- return passwordEdit.getText().toString();
- }
-
- @Override
- protected void onRestoreInstanceState(final Bundle savedInstanceState) {
- super.onRestoreInstanceState(savedInstanceState);
- usernameEdit.setText(savedInstanceState.getString(saveUsername));
- passwordEdit.setText(savedInstanceState.getString(savePassword));
- if(savedInstanceState.getBoolean(saveProgressDailog)) {
- performLogin();
- }
- String errorMessage=savedInstanceState.getString(saveErrorMessage);
- if(sessionManager.isUserLoggedIn()) {
- showMessage(R.string.login_success, R.color.primaryDarkColor);
- } else {
- showMessage(errorMessage, R.color.secondaryDarkColor);
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
new file mode 100644
index 000000000..75c4ac26d
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
@@ -0,0 +1,404 @@
+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 fr.free.nrw.commons.BuildConfig
+import fr.free.nrw.commons.CommonsApplication
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.Utils
+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.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 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)
+ }
+
+ 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)
+
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ 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(::onEditorAction)
+
+ 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)
+ }
+ }
+ }
+
+ 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_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() =
+ Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
+
+ private fun onPrivacyPolicyClicked() =
+ Utils.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,
+ twoFactorCode,
+ Locale.getDefault().language,
+ object : LoginCallback {
+ override fun success(loginResult: LoginResult) = runOnUiThread {
+ Timber.d("Login Success")
+ progressDialog!!.dismiss()
+ onLoginSuccess(loginResult)
+ }
+
+ override fun twoFactorPrompt(caught: Throwable, token: String?) = runOnUiThread {
+ Timber.d("Requesting 2FA prompt")
+ progressDialog!!.dismiss()
+ askUserForTwoFactorAuth()
+ }
+
+ 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() {
+ progressDialog!!.dismiss()
+ with(binding!!) {
+ twoFactorContainer.visibility = View.VISIBLE
+ loginTwoFactor.visibility = View.VISIBLE
+ loginTwoFactor.requestFocus()
+ }
+ val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
+ showMessageAndCancelDialog(R.string.login_failed_2fa_needed)
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(@StringRes resId: Int) {
+ showMessage(resId, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showMessageAndCancelDialog(error: String) {
+ showMessage(error, R.color.secondaryDarkColor)
+ progressDialog?.cancel()
+ }
+
+ @VisibleForTesting
+ fun showSuccessAndDismissDialog() {
+ showMessage(R.string.login_success, R.color.primaryDarkColor)
+ progressDialog!!.dismiss()
+ }
+
+ @VisibleForTesting
+ fun startMainActivity() {
+ startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ finish()
+ }
+
+ private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = getString(resId)
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
+ errorMessage.text = message
+ errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
+ errorMessageContainer.visibility = View.VISIBLE
+ }
+
+ private fun onTextChanged(text: String) {
+ val enabled =
+ binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
+ (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
+ binding!!.loginButton.isEnabled = enabled
+ }
+
+ companion object {
+ fun startYourself(context: Context) =
+ context.startActivity(Intent(context, LoginActivity::class.java))
+
+ const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
+ const val SAVE_ERROR_MESSAGE: String = "errorMessage"
+ const val SAVE_USERNAME: String = "username"
+ const val SAVE_PASSWORD: String = "password"
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java b/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
deleted file mode 100644
index 5b3ed08d7..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-
-import org.wikipedia.dataclient.Service;
-import org.wikipedia.dataclient.mwapi.MwPostResponse;
-
-import java.util.Objects;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import io.reactivex.Observable;
-
-/**
- * Handler for logout
- */
-@Singleton
-public class LogoutClient {
-
- private final Service service;
-
- @Inject
- public LogoutClient(@Named("commons-service") Service service) {
- this.service = service;
- }
-
- /**
- * Fetches the CSRF token and uses that to post the logout api call
- * @return
- */
- public Observable postLogout() {
- return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
- Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
deleted file mode 100644
index a7905f8ea..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.Account;
-import android.accounts.AccountManager;
-import android.content.Context;
-import android.os.Build;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.wikipedia.login.LoginResult;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.kvstore.JsonKvStore;
-import io.reactivex.Completable;
-import io.reactivex.Observable;
-
-/**
- * Manage the current logged in user session.
- */
-@Singleton
-public class SessionManager {
- private final Context context;
- private Account currentAccount; // Unlike a savings account... ;-)
- private JsonKvStore defaultKvStore;
-
- @Inject
- public SessionManager(Context context,
- @Named("default_preferences") JsonKvStore defaultKvStore) {
- this.context = context;
- this.currentAccount = null;
- this.defaultKvStore = defaultKvStore;
- }
-
- private boolean createAccount(@NonNull String userName, @NonNull String password) {
- Account account = getCurrentAccount();
- if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) {
- removeAccount();
- account = new Account(userName, BuildConfig.ACCOUNT_TYPE);
- return accountManager().addAccountExplicitly(account, password, null);
- }
- return true;
- }
-
- private void removeAccount() {
- Account account = getCurrentAccount();
- if (account != null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
- accountManager().removeAccountExplicitly(account);
- } else {
- //noinspection deprecation
- accountManager().removeAccount(account, null, null);
- }
- }
- }
-
- public void updateAccount(LoginResult result) {
- boolean accountCreated = createAccount(result.getUserName(), result.getPassword());
- if (accountCreated) {
- setPassword(result.getPassword());
- }
- }
-
- private void setPassword(@NonNull String password) {
- Account account = getCurrentAccount();
- if (account != null) {
- accountManager().setPassword(account, password);
- }
- }
-
- /**
- * @return Account|null
- */
- @Nullable
- public Account getCurrentAccount() {
- if (currentAccount == null) {
- AccountManager accountManager = AccountManager.get(context);
- Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- if (allAccounts.length != 0) {
- currentAccount = allAccounts[0];
- }
- }
- return currentAccount;
- }
-
- public boolean doesAccountExist() {
- return getCurrentAccount() != null;
- }
-
- @Nullable
- public String getUserName() {
- Account account = getCurrentAccount();
- return account == null ? null : account.name;
- }
-
- @Nullable
- public String getPassword() {
- Account account = getCurrentAccount();
- return account == null ? null : accountManager().getPassword(account);
- }
-
- private AccountManager accountManager() {
- return AccountManager.get(context);
- }
-
- public boolean isUserLoggedIn() {
- return defaultKvStore.getBoolean("isUserLoggedIn", false);
- }
-
- void setUserLoggedIn(boolean isLoggedIn) {
- defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
- }
-
- public void forceLogin(Context context) {
- if (context != null) {
- LoginActivity.startYourself(context);
- }
- }
-
- /**
- * 1. Clears existing accounts from account manager
- * 2. Calls MediaWikiApi's logout function to clear cookies
- * @return
- */
- public Completable logout() {
- AccountManager accountManager = AccountManager.get(context);
- Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
- return Completable.fromObservable(Observable.fromArray(allAccounts)
- .map(a -> accountManager.removeAccount(a, null, null).getResult()))
- .doOnComplete(() -> {
- currentAccount = null;
- });
- }
-
- /**
- * Return a corresponding boolean preference
- *
- * @param key
- * @return
- */
- public boolean getPreference(String key) {
- return defaultKvStore.getBoolean(key);
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
new file mode 100644
index 000000000..c9eb7d2f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
@@ -0,0 +1,95 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.Context
+import android.os.Build
+import android.text.TextUtils
+import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
+import fr.free.nrw.commons.auth.login.LoginResult
+import fr.free.nrw.commons.kvstore.JsonKvStore
+import io.reactivex.Completable
+import io.reactivex.Observable
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Manage the current logged in user session.
+ */
+@Singleton
+class SessionManager @Inject constructor(
+ private val context: Context,
+ @param:Named("default_preferences") private val defaultKvStore: JsonKvStore
+) {
+ private val accountManager: AccountManager get() = AccountManager.get(context)
+
+ private var _currentAccount: Account? = null // Unlike a savings account... ;-)
+ val currentAccount: Account? get() {
+ if (_currentAccount == null) {
+ val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
+ if (allAccounts.isNotEmpty()) {
+ _currentAccount = allAccounts[0]
+ }
+ }
+ return _currentAccount
+ }
+
+ val userName: String?
+ get() = currentAccount?.name
+
+ var password: String?
+ get() = currentAccount?.let { accountManager.getPassword(it) }
+ private set(value) {
+ currentAccount?.let { accountManager.setPassword(it, value) }
+ }
+
+ val isUserLoggedIn: Boolean
+ get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
+
+ fun updateAccount(result: LoginResult) {
+ if (createAccount(result.userName!!, result.password!!)) {
+ password = result.password
+ }
+ }
+
+ fun doesAccountExist(): Boolean =
+ currentAccount != null
+
+ fun setUserLoggedIn(isLoggedIn: Boolean) =
+ defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
+
+ fun forceLogin(context: Context?) =
+ context?.let { LoginActivity.startYourself(it) }
+
+ fun getPreference(key: String): Boolean =
+ defaultKvStore.getBoolean(key)
+
+ fun logout(): Completable = Completable.fromObservable(
+ Observable.empty()
+ .doOnComplete {
+ removeAccount()
+ _currentAccount = null
+ }
+ )
+
+ private fun createAccount(userName: String, password: String): Boolean {
+ var account = currentAccount
+ if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
+ removeAccount()
+ account = Account(userName, ACCOUNT_TYPE)
+ return accountManager.addAccountExplicitly(account, password, null)
+ }
+ return true
+ }
+
+ private fun removeAccount() {
+ currentAccount?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+ accountManager.removeAccountExplicitly(it)
+ } else {
+ accountManager.removeAccount(it, null, null)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
deleted file mode 100644
index d6e4568fd..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.os.Bundle;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.widget.Toast;
-
-import fr.free.nrw.commons.BuildConfig;
-import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.theme.BaseActivity;
-import timber.log.Timber;
-
-public class SignupActivity extends BaseActivity {
-
- private WebView webView;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- Timber.d("Signup Activity started");
-
- webView = new WebView(this);
- setContentView(webView);
-
- webView.setWebViewClient(new MyWebViewClient());
- WebSettings webSettings = webView.getSettings();
- /*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
- trust Wikimedia's site... right?*/
- webSettings.setJavaScriptEnabled(true);
-
- webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
- }
-
- private class MyWebViewClient extends WebViewClient {
- @Override
- public boolean shouldOverrideUrlLoading(WebView view, String url) {
- if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
- //Signup success, so clear cookies, notify user, and load LoginActivity again
- Timber.d("Overriding URL %s", url);
-
- Toast toast = Toast.makeText(SignupActivity.this,
- R.string.account_created, Toast.LENGTH_LONG);
- toast.show();
- // terminate on task completion.
- finish();
- return true;
- } else {
- //If user clicks any other links in the webview
- Timber.d("Not overriding URL, URL is: %s", url);
- return false;
- }
- }
- }
-
- @Override
- public void onBackPressed() {
- if (webView.canGoBack()) {
- webView.goBack();
- } else {
- super.onBackPressed();
- }
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
new file mode 100644
index 000000000..5b48ecd8f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
@@ -0,0 +1,75 @@
+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 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)
+ with(webView!!) {
+ setContentView(this)
+ webViewClient = MyWebViewClient()
+ // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
+ // trust Wikimedia's site... right?
+ settings.javaScriptEnabled = true
+ loadUrl(BuildConfig.SIGNUP_LANDING_URL)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (webView!!.canGoBack()) {
+ webView!!.goBack()
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+ /**
+ * Known bug in androidx.appcompat library version 1.1.0 being tracked here
+ * https://issuetracker.google.com/issues/141132133
+ * App tries to put light/dark theme to webview and crashes in the process
+ * This code tries to prevent applying the theme when sdk is between api 21 to 25
+ */
+ override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
+ if (Build.VERSION.SDK_INT <= 25 &&
+ (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
+ ) return
+ super.applyOverrideConfiguration(overrideConfiguration)
+ }
+
+ private inner class MyWebViewClient : WebViewClient() {
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
+ if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
+ //Signup success, so clear cookies, notify user, and load LoginActivity again
+ Timber.d("Overriding URL %s", url)
+
+ Toast.makeText(
+ this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
+ ).show()
+
+ // terminate on task completion.
+ finish()
+ true
+ } else {
+ //If user clicks any other links in the webview
+ Timber.d("Not overriding URL, URL is: %s", url)
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
deleted file mode 100644
index 643725604..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java
+++ /dev/null
@@ -1,141 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.accounts.Account;
-import android.accounts.AccountAuthenticatorResponse;
-import android.accounts.AccountManager;
-import android.accounts.NetworkErrorException;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.BuildConfig;
-
-import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
-
-/**
- * Handles WikiMedia commons account Authentication
- */
-public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
- private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY};
-
- @NonNull
- private final Context context;
-
- public WikiAccountAuthenticator(@NonNull Context context) {
- super(context);
- this.context = context;
- }
-
- /**
- * Provides Bundle with edited Account Properties
- */
- @Override
- public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
- Bundle bundle = new Bundle();
- bundle.putString("test", "editProperties");
- return bundle;
- }
-
- @Override
- public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
- @NonNull String accountType, @Nullable String authTokenType,
- @Nullable String[] requiredFeatures, @Nullable Bundle options)
- throws NetworkErrorException {
- // account type not supported returns bundle without loginActivity Intent, it just contains "test" key
- if (!supportedAccountType(accountType)) {
- Bundle bundle = new Bundle();
- bundle.putString("test", "addAccount");
- return bundle;
- }
-
- return addAccount(response);
- }
-
- @Override
- public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "confirmCredentials");
- return bundle;
- }
-
- @Override
- public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @NonNull String authTokenType,
- @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "getAuthToken");
- return bundle;
- }
-
- @Nullable
- @Override
- public String getAuthTokenLabel(@NonNull String authTokenType) {
- return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
- }
-
- @Nullable
- @Override
- public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @Nullable String authTokenType,
- @Nullable Bundle options)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putString("test", "updateCredentials");
- return bundle;
- }
-
- @Nullable
- @Override
- public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
- @NonNull Account account, @NonNull String[] features)
- throws NetworkErrorException {
- Bundle bundle = new Bundle();
- bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
- return bundle;
- }
-
- private boolean supportedAccountType(@Nullable String type) {
- return BuildConfig.ACCOUNT_TYPE.equals(type);
- }
-
- /**
- * Provides a bundle containing a Parcel
- * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
- */
- private Bundle addAccount(AccountAuthenticatorResponse response) {
- Intent intent = new Intent(context, LoginActivity.class);
- intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
-
- Bundle bundle = new Bundle();
- bundle.putParcelable(AccountManager.KEY_INTENT, intent);
-
- return bundle;
- }
-
- @Override
- public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
- Account account) throws NetworkErrorException {
- Bundle result = super.getAccountRemovalAllowed(response, account);
-
- if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
- && !result.containsKey(AccountManager.KEY_INTENT)) {
- boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
-
- if (allowed) {
- for (String auth : SYNC_AUTHORITIES) {
- ContentResolver.cancelSync(account, auth);
- }
- }
- }
-
- return result;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
new file mode 100644
index 000000000..367989f14
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt
@@ -0,0 +1,108 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.accounts.NetworkErrorException
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import fr.free.nrw.commons.BuildConfig
+
+private val SYNC_AUTHORITIES = arrayOf(
+ BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
+)
+
+/**
+ * Handles WikiMedia commons account Authentication
+ */
+class WikiAccountAuthenticator(
+ private val context: Context
+) : AbstractAccountAuthenticator(context) {
+ /**
+ * Provides Bundle with edited Account Properties
+ */
+ override fun editProperties(
+ response: AccountAuthenticatorResponse,
+ accountType: String
+ ) = bundleOf("test" to "editProperties")
+
+ // account type not supported returns bundle without loginActivity Intent, it just contains "test" key
+ @Throws(NetworkErrorException::class)
+ override fun addAccount(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle?
+ ) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
+ addAccount(response)
+ } else {
+ bundleOf("test" to "addAccount")
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse, account: Account, options: Bundle?
+ ) = bundleOf("test" to "confirmCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String,
+ options: Bundle?
+ ) = bundleOf("test" to "getAuthToken")
+
+ override fun getAuthTokenLabel(authTokenType: String) =
+ if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
+
+ @Throws(NetworkErrorException::class)
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse,
+ account: Account,
+ authTokenType: String?,
+ options: Bundle?
+ ) = bundleOf("test" to "updateCredentials")
+
+ @Throws(NetworkErrorException::class)
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse,
+ account: Account, features: Array
+ ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
+
+ /**
+ * Provides a bundle containing a Parcel
+ * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
+ */
+ private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
+ val intent = Intent(context, LoginActivity::class.java)
+ .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+ return bundleOf(AccountManager.KEY_INTENT to intent)
+ }
+
+ @Throws(NetworkErrorException::class)
+ override fun getAccountRemovalAllowed(
+ response: AccountAuthenticatorResponse?,
+ account: Account?
+ ): Bundle {
+ val result = super.getAccountRemovalAllowed(response, account)
+
+ if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
+ && !result.containsKey(AccountManager.KEY_INTENT)
+ ) {
+ val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
+
+ if (allowed) {
+ for (auth in SYNC_AUTHORITIES) {
+ ContentResolver.cancelSync(account, auth)
+ }
+ }
+ }
+
+ return result
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
deleted file mode 100644
index bb41f27aa..000000000
--- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package fr.free.nrw.commons.auth;
-
-import android.accounts.AbstractAccountAuthenticator;
-import android.content.Intent;
-import android.os.IBinder;
-
-import androidx.annotation.Nullable;
-
-import fr.free.nrw.commons.di.CommonsDaggerService;
-
-/**
- * Handles the Auth service of the App, see AndroidManifests for details
- * (Uses Dagger 2 as injector)
- */
-public class WikiAccountAuthenticatorService extends CommonsDaggerService {
-
- @Nullable
- private AbstractAccountAuthenticator authenticator;
-
- @Override
- public void onCreate() {
- super.onCreate();
- authenticator = new WikiAccountAuthenticator(this);
- }
-
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return authenticator == null ? null : authenticator.getIBinder();
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
new file mode 100644
index 000000000..852536a48
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt
@@ -0,0 +1,22 @@
+package fr.free.nrw.commons.auth
+
+import android.accounts.AbstractAccountAuthenticator
+import android.content.Intent
+import android.os.IBinder
+import fr.free.nrw.commons.di.CommonsDaggerService
+
+/**
+ * Handles the Auth service of the App, see AndroidManifests for details
+ * (Uses Dagger 2 as injector)
+ */
+class WikiAccountAuthenticatorService : CommonsDaggerService() {
+ private var authenticator: AbstractAccountAuthenticator? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ authenticator = WikiAccountAuthenticator(this)
+ }
+
+ override fun onBind(intent: Intent): IBinder? =
+ authenticator?.iBinder
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
new file mode 100644
index 000000000..f35e5f003
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt
@@ -0,0 +1,206 @@
+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()
+ },
+ )
+
+ @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(
+ caught: Throwable,
+ token: String?,
+ ) = callback.twoFactorPrompt()
+
+ // 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()
+ }
+
+ companion object {
+ private const val ANON_TOKEN = "+\\"
+ private const val MAX_RETRIES = 1
+ private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2
+ const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure."
+ const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token."
+ }
+}
+
+class InvalidLoginTokenException(
+ message: String,
+) : Exception(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
new file mode 100644
index 000000000..949f2dddb
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt
@@ -0,0 +1,13 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
+import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Headers
+
+interface CsrfTokenInterface {
+ @Headers("Cache-Control: no-cache")
+ @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf")
+ fun getCsrfTokenCall(): Call
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
new file mode 100644
index 000000000..84481c918
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt
@@ -0,0 +1,12 @@
+package fr.free.nrw.commons.auth.csrf
+
+import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
+import javax.inject.Inject
+
+class LogoutClient
+ @Inject
+ constructor(
+ private val store: CommonsCookieStorage,
+ ) {
+ fun logout() = store.clear()
+ }
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
new file mode 100644
index 000000000..8092f73ae
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt
@@ -0,0 +1,14 @@
+package fr.free.nrw.commons.auth.login
+
+interface LoginCallback {
+ fun success(loginResult: LoginResult)
+
+ fun twoFactorPrompt(
+ caught: Throwable,
+ token: String?,
+ )
+
+ fun passwordResetPrompt(token: String?)
+
+ fun error(caught: Throwable)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
new file mode 100644
index 000000000..2a799c847
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
@@ -0,0 +1,258 @@
+package fr.free.nrw.commons.auth.login
+
+import android.text.TextUtils
+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,
+ 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?,
+ loginToken: String?,
+ userLanguage: String,
+ cb: LoginCallback,
+ ) {
+ this.userLanguage = userLanguage
+
+ loginCall =
+ if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ retypedPassword,
+ twoFactorCode,
+ 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(
+ 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,
+ 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, twoFactorCode, 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?,
+ ) {
+ 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()) {
+ loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
+ } else {
+ loginInterface.postLogIn(
+ userName,
+ password,
+ null,
+ twoFactorCode,
+ 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) {
+ // TODO: Find a better way to boil up the warning about 2FA
+ throw LoginFailedException(loginResult.message)
+ }
+ throw LoginFailedException(loginResult.message)
+ }
+
+ if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
+ throw LoginFailedException(loginResult.message)
+ }
+ }
+
+ private fun getExtendedInfo(
+ userName: String,
+ loginResult: LoginResult,
+ cb: LoginCallback,
+ ) = loginInterface
+ .getUserInfo(userName)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe({ response: MwQueryResponse? ->
+ loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
+ loginResult.groups =
+ response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
+ cb.success(loginResult)
+ }, { caught: Throwable ->
+ Timber.e(caught, "Login succeeded but getting group information failed. ")
+ cb.error(caught)
+ })
+
+ fun cancel() {
+ tokenCall?.let {
+ it.cancel()
+ tokenCall = null
+ }
+
+ loginCall?.let {
+ it.cancel()
+ loginCall = null
+ }
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
new file mode 100644
index 000000000..fb5ad14c6
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt
@@ -0,0 +1,5 @@
+package fr.free.nrw.commons.auth.login
+
+class LoginFailedException(
+ message: String?,
+) : Throwable(message)
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
new file mode 100644
index 000000000..07e1cd45c
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt
@@ -0,0 +1,47 @@
+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("logintoken") token: String?,
+ @Field("uselang") userLanguage: String?,
+ @Field("logincontinue") loginContinue: Boolean,
+ ): Call
+
+ @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
+ fun getUserInfo(
+ @Query("ususers") userName: String,
+ ): Observable
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
new file mode 100644
index 000000000..a96778e38
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt
@@ -0,0 +1,61 @@
+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.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) {
+ if (requests != null) {
+ for (req in requests) {
+ if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest" == req.id()) {
+ return OAuthResult(status, userName, password, message)
+ } else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest" == req.id()) {
+ 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
+ private val fields: Map? = null
+
+ fun id(): String? = id
+}
+
+internal class RequestField {
+ private val type: String? = null
+ private val label: String? = null
+ private val help: String? = null
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
new file mode 100644
index 000000000..6a7594ec0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt
@@ -0,0 +1,33 @@
+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 ResetPasswordResult(
+ status: String,
+ userName: String?,
+ password: String?,
+ message: String?,
+ ) : LoginResult(status, userName, password, message)
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt
deleted file mode 100644
index 260847c3f..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/Bookmark.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package fr.free.nrw.commons.bookmarks
-
-import android.net.Uri
-
-class Bookmark(mediaName: String?, mediaCreator: String?,
- /**
- * Modifies the content URI - marking this bookmark as already saved in the database
- * @param contentUri the content URI
- */
- var contentUri: Uri?) {
- /**
- * Gets the content URI for this bookmark
- * @return content URI
- */
- /**
- * Gets the media name
- * @return the media name
- */
- val mediaName: String = mediaName ?: ""
- /**
- * Gets media creator
- * @return creator name
- */
- val mediaCreator: String = mediaCreator ?: ""
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.java
index 1a50f65a6..9100fb63c 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.java
@@ -5,23 +5,15 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
-
-import com.google.android.material.tabs.TabLayout;
-
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.explore.ParentViewPager;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.theme.BaseActivity;
import javax.inject.Inject;
-
-import butterknife.BindView;
-import butterknife.ButterKnife;
-import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionController;
import javax.inject.Named;
@@ -29,12 +21,7 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
private FragmentManager supportFragmentManager;
private BookmarksPagerAdapter adapter;
- @BindView(R.id.viewPagerBookmarks)
- ParentViewPager viewPager;
- @BindView(R.id.tab_layout)
- TabLayout tabLayout;
- @BindView(R.id.fragmentContainer)
- FrameLayout fragmentContainer;
+ FragmentBookmarksBinding binding;
@Inject
ContributionController controller;
@@ -54,7 +41,9 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
}
public void setScroll(boolean canScroll) {
- viewPager.setCanScroll(canScroll);
+ if (binding!=null) {
+ binding.viewPagerBookmarks.setCanScroll(canScroll);
+ }
}
@Override
@@ -68,8 +57,7 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
- View view = inflater.inflate(R.layout.fragment_bookmarks, container, false);
- ButterKnife.bind(this, view);
+ binding = FragmentBookmarksBinding.inflate(inflater, container, false);
// Activity can call methods in the fragment by acquiring a
// reference to the Fragment from FragmentManager, using findFragmentById()
@@ -77,14 +65,14 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
adapter = new BookmarksPagerAdapter(supportFragmentManager, getContext(),
applicationKvStore.getBoolean("login_skipped"));
- viewPager.setAdapter(adapter);
- tabLayout.setupWithViewPager(viewPager);
+ binding.viewPagerBookmarks.setAdapter(adapter);
+ binding.tabLayout.setupWithViewPager(binding.viewPagerBookmarks);
((MainActivity) getActivity()).showTabs();
((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false);
setupTabLayout();
- return view;
+ return binding.getRoot();
}
/**
@@ -92,15 +80,15 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
* visibility of tabLayout to gone.
*/
public void setupTabLayout() {
- tabLayout.setVisibility(View.VISIBLE);
+ binding.tabLayout.setVisibility(View.VISIBLE);
if (adapter.getCount() == 1) {
- tabLayout.setVisibility(View.GONE);
+ binding.tabLayout.setVisibility(View.GONE);
}
}
public void onBackPressed() {
- if (((BookmarkListRootFragment) (adapter.getItem(tabLayout.getSelectedTabPosition())))
+ if (((BookmarkListRootFragment) (adapter.getItem(binding.tabLayout.getSelectedTabPosition())))
.backPressed()) {
// The event is handled internally by the adapter , no further action required.
return;
@@ -108,4 +96,10 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
// Event is not handled by the adapter ( performed back action ) change action bar.
((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false);
}
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ binding = null;
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java
index 17521bae2..ca7dd3f3b 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java
@@ -2,31 +2,30 @@ package fr.free.nrw.commons.bookmarks;
import android.content.Context;
import android.os.Bundle;
-import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
-import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
-import butterknife.BindView;
-import butterknife.ButterKnife;
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.navtab.NavTab;
import java.util.ArrayList;
import java.util.Iterator;
+import timber.log.Timber;
public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements
FragmentManager.OnBackStackChangedListener,
@@ -39,8 +38,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
public Fragment listFragment;
private BookmarksPagerAdapter bookmarksPagerAdapter;
- @BindView(R.id.explore_container)
- FrameLayout container;
+ FragmentFeaturedRootBinding binding;
public BookmarkListRootFragment() {
//empty constructor necessary otherwise crashes on recreate
@@ -50,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
String title = bundle.getString("categoryName");
int order = bundle.getInt("order");
final int orderItem = bundle.getInt("orderItem");
- if (order == 0) {
- listFragment = new BookmarkPicturesFragment();
- } else {
- listFragment = new BookmarkLocationsFragment();
+
+ switch (order){
+ case 0: listFragment = new BookmarkPicturesFragment();
+ break;
+
+ case 1: listFragment = new BookmarkLocationsFragment();
+ break;
+
+ case 3: listFragment = new BookmarkCategoriesFragment();
+ break;
+ }
if(orderItem == 2) {
listFragment = new BookmarkItemsFragment();
}
- }
+
Bundle featuredArguments = new Bundle();
featuredArguments.putString("categoryName", title);
listFragment.setArguments(featuredArguments);
@@ -70,9 +75,8 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- View view = inflater.inflate(R.layout.fragment_featured_root, container, false);
- ButterKnife.bind(this, view);
- return view;
+ binding = FragmentFeaturedRootBinding.inflate(inflater, container, false);
+ return binding.getRoot();
}
@Override
@@ -132,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
@Override
public void onMediaClicked(int position) {
- Log.d("deneme8", "on media clicked");
+ Timber.d("on media clicked");
/*container.setVisibility(View.VISIBLE);
((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE);
mediaDetails = new MediaDetailPagerFragment(false, true, position);
@@ -184,7 +188,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
public void refreshNominatedMedia(int index) {
if (mediaDetails != null && !listFragment.isVisible()) {
removeFragment(mediaDetails);
- mediaDetails = new MediaDetailPagerFragment(false, true);
+ mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
((BookmarkFragment) getParentFragment()).setScroll(false);
setFragment(mediaDetails, listFragment);
mediaDetails.showImage(index);
@@ -206,10 +210,6 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
//check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException
if (mediaDetails != null) {
if (mediaDetails.isVisible()) {
- if (mediaDetails.backButtonClicked()) {
- // mediaDetails handled the back clicked , no further action required.
- return true;
- }
// todo add get list fragment
((BookmarkFragment) getParentFragment()).setupTabLayout();
ArrayList removed = mediaDetails.getRemovedItems();
@@ -244,10 +244,10 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
@Override
public void onItemClick(AdapterView> parent, View view, int position, long id) {
- Log.d("deneme8", "on media clicked");
- container.setVisibility(View.VISIBLE);
- ((BookmarkFragment) getParentFragment()).tabLayout.setVisibility(View.GONE);
- mediaDetails = new MediaDetailPagerFragment(false, true);
+ Timber.d("on media clicked");
+ binding.exploreContainer.setVisibility(View.VISIBLE);
+ ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
+ mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
((BookmarkFragment) getParentFragment()).setScroll(false);
setFragment(mediaDetails, listFragment);
mediaDetails.showImage(position);
@@ -257,4 +257,10 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
public void onBackStackChanged() {
}
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ binding = null;
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java
deleted file mode 100644
index 71690c5e2..000000000
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package fr.free.nrw.commons.bookmarks;
-
-import androidx.fragment.app.Fragment;
-
-/**
- * Data class for handling a bookmark fragment and it title
- */
-public class BookmarkPages {
- private Fragment page;
- private String title;
-
- BookmarkPages(Fragment fragment, String title) {
- this.title = title;
- this.page = fragment;
- }
-
- /**
- * Return the fragment
- * @return fragment object
- */
- public Fragment getPage() {
- return page;
- }
-
- /**
- * Return the fragment title
- * @return title
- */
- public String getTitle() {
- return title;
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt
new file mode 100644
index 000000000..e0ade52fe
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt
@@ -0,0 +1,8 @@
+package fr.free.nrw.commons.bookmarks
+
+import androidx.fragment.app.Fragment
+
+data class BookmarkPages (
+ val page: Fragment? = null,
+ val title: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java
index 6ef2f1732..f0620032a 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java
@@ -12,7 +12,6 @@ import androidx.fragment.app.FragmentPagerAdapter;
import java.util.ArrayList;
import fr.free.nrw.commons.R;
-import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment;
public class BookmarksPagerAdapter extends FragmentPagerAdapter {
@@ -50,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter {
new BookmarkListRootFragment(locationBundle, this),
context.getString(R.string.title_page_bookmarks_items)));
}
+ final Bundle categoriesBundle = new Bundle();
+ categoriesBundle.putString("categoryName",
+ context.getString(R.string.title_page_bookmarks_categories));
+ categoriesBundle.putInt("order", 3);
+ pages.add(new BookmarkPages(
+ new BookmarkListRootFragment(categoriesBundle, this),
+ context.getString(R.string.title_page_bookmarks_categories)));
notifyDataSetChanged();
}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt
new file mode 100644
index 000000000..71a2d1ec9
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt
@@ -0,0 +1,52 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Bookmark categories dao
+ *
+ * @constructor Create empty Bookmark categories dao
+ */
+@Dao
+interface BookmarkCategoriesDao {
+
+ /**
+ * Insert or Delete category bookmark into DB
+ *
+ * @param bookmarksCategoryModal
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal)
+
+
+ /**
+ * Delete category bookmark from DB
+ *
+ * @param bookmarksCategoryModal
+ */
+ @Delete
+ suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal)
+
+ /**
+ * Checks if given category exist in DB
+ *
+ * @param categoryName
+ * @return
+ */
+ @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)")
+ suspend fun doesExist(categoryName: String): Boolean
+
+ /**
+ * Get all categories
+ *
+ * @return
+ */
+ @Query("SELECT * FROM bookmarks_categories")
+ fun getAllCategories(): Flow>
+
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt
new file mode 100644
index 000000000..ef5bc613d
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt
@@ -0,0 +1,143 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dagger.android.support.DaggerFragment
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.category.CategoryDetailsActivity
+import javax.inject.Inject
+
+/**
+ * Tab fragment to show list of bookmarked Categories
+ */
+class BookmarkCategoriesFragment : DaggerFragment() {
+
+ @Inject
+ lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MaterialTheme(
+ colorScheme = if (isSystemInDarkTheme()) darkColorScheme(
+ primary = colorResource(R.color.primaryDarkColor),
+ surface = colorResource(R.color.main_background_dark),
+ background = colorResource(R.color.main_background_dark)
+ ) else lightColorScheme(
+ primary = colorResource(R.color.primaryColor),
+ surface = colorResource(R.color.main_background_light),
+ background = colorResource(R.color.main_background_light)
+ )
+ ) {
+ val listOfBookmarks by bookmarkCategoriesDao.getAllCategories()
+ .collectAsStateWithLifecycle(initialValue = emptyList())
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Box(contentAlignment = Alignment.Center) {
+ if (listOfBookmarks.isEmpty()) {
+ Text(
+ text = stringResource(R.string.bookmark_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF)
+ else Color(
+ 0x8A000000
+ )
+ )
+ } else {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(items = listOfBookmarks) { bookmarkItem ->
+ CategoryItem(
+ categoryName = bookmarkItem.categoryName,
+ onClick = {
+ val categoryDetailsIntent = Intent(
+ requireContext(),
+ CategoryDetailsActivity::class.java
+ ).putExtra("categoryName", it)
+ startActivity(categoryDetailsIntent)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ @Composable
+ fun CategoryItem(
+ modifier: Modifier = Modifier,
+ onClick: (String) -> Unit,
+ categoryName: String
+ ) {
+ Row(modifier = modifier.clickable {
+ onClick(categoryName)
+ }) {
+ ListItem(
+ leadingContent = {
+ Image(
+ modifier = Modifier.size(48.dp),
+ painter = painterResource(R.drawable.commons),
+ contentDescription = null
+ )
+ },
+ headlineContent = {
+ Text(
+ text = categoryName,
+ maxLines = 2,
+ color = if (isSystemInDarkTheme()) Color.White else Color.Black,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ )
+ }
+ }
+
+ @Preview
+ @Composable
+ private fun CategoryItemPreview() {
+ CategoryItem(
+ onClick = {},
+ categoryName = "Test Category"
+ )
+ }
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt
new file mode 100644
index 000000000..ab679611f
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt
@@ -0,0 +1,15 @@
+package fr.free.nrw.commons.bookmarks.category
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Data class representing bookmarked category in DB
+ *
+ * @property categoryName
+ * @constructor Create empty Bookmarks category modal
+ */
+@Entity(tableName = "bookmarks_categories")
+data class BookmarksCategoryModal(
+ @PrimaryKey val categoryName: String
+)
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt
index 64bdf5315..4233d9508 100644
--- 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
@@ -15,25 +15,34 @@ 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) {
-
+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)
+ 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)
+ 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) {
-
+ override fun onBindViewHolder(
+ holder: BookmarkItemViewHolder,
+ position: Int,
+ ) {
val depictedItem = list[position]
holder.depictsLabel.text = depictedItem.name
holder.description.text = depictedItem.description
@@ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List, val context: Context)
}
}
- override fun getItemCount(): Int {
- return list.size
- }
-}
\ No newline at end of file
+ override fun getItemCount(): Int = list.size
+}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java
index 08e54a114..6788a8290 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java
@@ -1,5 +1,6 @@
package fr.free.nrw.commons.bookmarks.items;
+import android.annotation.SuppressLint;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
@@ -134,6 +135,7 @@ public class BookmarkItemsDao {
* @param cursor : Object for storing database data
* @return DepictedItem
*/
+ @SuppressLint("Range")
DepictedItem fromCursor(final Cursor cursor) {
final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME));
final String description
@@ -309,22 +311,18 @@ public class BookmarkItemsDao {
if (from == to) {
return;
}
- if (from < 7) {
+ if (from < 18) {
+ // doesn't exist yet
from++;
onUpdate(db, from, to);
return;
}
- if (from == 7) {
+ if (from == 18) {
+ // table added in version 19
onCreate(db);
from++;
onUpdate(db, from, to);
- return;
- }
-
- if (from == 8) {
- from++;
- onUpdate(db, from, to);
}
}
}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.java
index ef5c1fa9e..75a0fa7a4 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.java
@@ -12,10 +12,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
-import butterknife.BindView;
-import butterknife.ButterKnife;
import dagger.android.support.DaggerFragment;
import fr.free.nrw.commons.R;
+import fr.free.nrw.commons.databinding.FragmentBookmarksItemsBinding;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.util.List;
import javax.inject.Inject;
@@ -26,17 +25,7 @@ import org.jetbrains.annotations.NotNull;
*/
public class BookmarkItemsFragment extends DaggerFragment {
- @BindView(R.id.status_message)
- TextView statusTextView;
-
- @BindView(R.id.loading_images_progress_bar)
- ProgressBar progressBar;
-
- @BindView(R.id.list_view)
- RecyclerView recyclerView;
-
- @BindView(R.id.parent_layout)
- RelativeLayout parentLayout;
+ private FragmentBookmarksItemsBinding binding;
@Inject
BookmarkItemsController controller;
@@ -51,16 +40,13 @@ public class BookmarkItemsFragment extends DaggerFragment {
final ViewGroup container,
final Bundle savedInstanceState
) {
- final View v = inflater.inflate(R.layout.fragment_bookmarks_items, container, false);
- ButterKnife.bind(this, v);
- return v;
+ binding = FragmentBookmarksItemsBinding.inflate(inflater, container, false);
+ return binding.getRoot();
}
@Override
public void onViewCreated(final @NotNull View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
- progressBar.setVisibility(View.VISIBLE);
- recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
initList(requireContext());
}
@@ -77,13 +63,19 @@ public class BookmarkItemsFragment extends DaggerFragment {
private void initList(final Context context) {
final List depictItems = controller.loadFavoritesItems();
final BookmarkItemsAdapter adapter = new BookmarkItemsAdapter(depictItems, context);
- recyclerView.setAdapter(adapter);
- progressBar.setVisibility(View.GONE);
+ binding.listView.setAdapter(adapter);
+ binding.loadingImagesProgressBar.setVisibility(View.GONE);
if (depictItems.isEmpty()) {
- statusTextView.setText(R.string.bookmark_empty);
- statusTextView.setVisibility(View.VISIBLE);
+ binding.statusMessage.setText(R.string.bookmark_empty);
+ binding.statusMessage.setVisibility(View.VISIBLE);
} else {
- statusTextView.setVisibility(View.GONE);
+ binding.statusMessage.setVisibility(View.GONE);
}
}
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ binding = null;
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java
index b3fcfeebe..fe4f603f4 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java
@@ -1,5 +1,6 @@
package fr.free.nrw.commons.bookmarks.locations;
+import android.annotation.SuppressLint;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
@@ -46,11 +47,11 @@ public class BookmarkLocationsDao {
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
- BookmarkLocationsContentProvider.BASE_URI,
- Table.ALL_FIELDS,
- null,
- new String[]{},
- null);
+ BookmarkLocationsContentProvider.BASE_URI,
+ Table.ALL_FIELDS,
+ null,
+ new String[]{},
+ null);
while (cursor != null && cursor.moveToNext()) {
items.add(fromCursor(cursor));
}
@@ -126,11 +127,11 @@ public class BookmarkLocationsDao {
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
- BookmarkLocationsContentProvider.BASE_URI,
- Table.ALL_FIELDS,
- Table.COLUMN_NAME + "=?",
- new String[]{bookmarkLocation.name},
- null);
+ BookmarkLocationsContentProvider.BASE_URI,
+ Table.ALL_FIELDS,
+ Table.COLUMN_NAME + "=?",
+ new String[]{bookmarkLocation.name},
+ null);
if (cursor != null && cursor.moveToFirst()) {
return true;
}
@@ -146,10 +147,11 @@ public class BookmarkLocationsDao {
return false;
}
+ @SuppressLint("Range")
@NonNull
Place fromCursor(final Cursor cursor) {
final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)),
- cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
+ cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
final Sitelinks.Builder builder = new Sitelinks.Builder();
builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK)));
@@ -175,8 +177,8 @@ public class BookmarkLocationsDao {
cv.put(BookmarkLocationsDao.Table.COLUMN_LANGUAGE, bookmarkLocation.getLanguage());
cv.put(BookmarkLocationsDao.Table.COLUMN_DESCRIPTION, bookmarkLocation.getLongDescription());
cv.put(BookmarkLocationsDao.Table.COLUMN_CATEGORY, bookmarkLocation.getCategory());
- cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel().getText());
- cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel().getIcon());
+ cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getText() : "");
+ cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getIcon() : null);
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIPEDIA_LINK, bookmarkLocation.siteLinks.getWikipediaLink().toString());
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIDATA_LINK, bookmarkLocation.siteLinks.getWikidataLink().toString());
cv.put(BookmarkLocationsDao.Table.COLUMN_COMMONS_LINK, bookmarkLocation.siteLinks.getCommonsLink().toString());
@@ -207,40 +209,40 @@ public class BookmarkLocationsDao {
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
public static final String[] ALL_FIELDS = {
- COLUMN_NAME,
- COLUMN_LANGUAGE,
- COLUMN_DESCRIPTION,
- COLUMN_CATEGORY,
- COLUMN_LABEL_TEXT,
- COLUMN_LABEL_ICON,
- COLUMN_LAT,
- COLUMN_LONG,
- COLUMN_IMAGE_URL,
- COLUMN_WIKIPEDIA_LINK,
- COLUMN_WIKIDATA_LINK,
- COLUMN_COMMONS_LINK,
- COLUMN_PIC,
- COLUMN_EXISTS,
+ COLUMN_NAME,
+ COLUMN_LANGUAGE,
+ COLUMN_DESCRIPTION,
+ COLUMN_CATEGORY,
+ COLUMN_LABEL_TEXT,
+ COLUMN_LABEL_ICON,
+ COLUMN_LAT,
+ COLUMN_LONG,
+ COLUMN_IMAGE_URL,
+ COLUMN_WIKIPEDIA_LINK,
+ COLUMN_WIKIDATA_LINK,
+ COLUMN_COMMONS_LINK,
+ COLUMN_PIC,
+ COLUMN_EXISTS,
};
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
- + COLUMN_NAME + " STRING PRIMARY KEY,"
- + COLUMN_LANGUAGE + " STRING,"
- + COLUMN_DESCRIPTION + " STRING,"
- + COLUMN_CATEGORY + " STRING,"
- + COLUMN_LABEL_TEXT + " STRING,"
- + COLUMN_LABEL_ICON + " INTEGER,"
- + COLUMN_LAT + " DOUBLE,"
- + COLUMN_LONG + " DOUBLE,"
- + COLUMN_IMAGE_URL + " STRING,"
- + COLUMN_WIKIPEDIA_LINK + " STRING,"
- + COLUMN_WIKIDATA_LINK + " STRING,"
- + COLUMN_COMMONS_LINK + " STRING,"
- + COLUMN_PIC + " STRING,"
- + COLUMN_EXISTS + " STRING"
- + ");";
+ + COLUMN_NAME + " STRING PRIMARY KEY,"
+ + COLUMN_LANGUAGE + " STRING,"
+ + COLUMN_DESCRIPTION + " STRING,"
+ + COLUMN_CATEGORY + " STRING,"
+ + COLUMN_LABEL_TEXT + " STRING,"
+ + COLUMN_LABEL_ICON + " INTEGER,"
+ + COLUMN_LAT + " DOUBLE,"
+ + COLUMN_LONG + " DOUBLE,"
+ + COLUMN_IMAGE_URL + " STRING,"
+ + COLUMN_WIKIPEDIA_LINK + " STRING,"
+ + COLUMN_WIKIDATA_LINK + " STRING,"
+ + COLUMN_COMMONS_LINK + " STRING,"
+ + COLUMN_PIC + " STRING,"
+ + COLUMN_EXISTS + " STRING"
+ + ");";
public static void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
@@ -308,4 +310,4 @@ public class BookmarkLocationsDao {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java
index 21d4d7460..f5ce556c4 100644
--- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java
+++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java
@@ -1,35 +1,33 @@
package fr.free.nrw.commons.bookmarks.locations;
+import android.Manifest.permission;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.ProgressBar;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import butterknife.BindView;
-import butterknife.ButterKnife;
import dagger.android.support.DaggerFragment;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionController;
+import fr.free.nrw.commons.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 java.util.List;
+import java.util.Map;
import javax.inject.Inject;
import kotlin.Unit;
public class BookmarkLocationsFragment extends DaggerFragment {
- @BindView(R.id.statusMessage) TextView statusTextView;
- @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
- @BindView(R.id.listView) RecyclerView recyclerView;
- @BindView(R.id.parentLayout) RelativeLayout parentLayout;
+ public FragmentBookmarksLocationsBinding binding;
@Inject BookmarkLocationsController controller;
@Inject ContributionController contributionController;
@@ -37,6 +35,42 @@ public class BookmarkLocationsFragment extends DaggerFragment {
@Inject CommonPlaceClickActions commonPlaceClickActions;
private PlaceAdapter adapter;
+ private final ActivityResultLauncher cameraPickLauncherForResult =
+ registerForActivityResult(new StartActivityForResult(),
+ result -> {
+ contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
+ contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
+ });
+ });
+
+ private final ActivityResultLauncher galleryPickLauncherForResult =
+ registerForActivityResult(new StartActivityForResult(),
+ result -> {
+ contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
+ contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
+ });
+ });
+
+ private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback