Merge branch 'main' into replace_toasts

This commit is contained in:
Priyank Shankar 2024-08-21 19:49:52 +05:30 committed by GitHub
commit 759ed34cf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
922 changed files with 29317 additions and 44373 deletions

View file

@ -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 }}
@ -12,17 +12,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2.4.0
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: 11
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
@ -37,7 +37,7 @@ jobs:
- name: AVD cache
if: github.event_name != 'pull_request'
uses: actions/cache@v2
uses: actions/cache@v3
id: avd-cache
with:
path: |
@ -89,7 +89,7 @@ jobs:
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
@ -98,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

4
.gitignore vendored
View file

@ -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

View file

@ -39,21 +39,18 @@
<option name="ALIGN_INIT_LIST_IN_COLUMNS" value="false" />
<option name="SPACE_BEFORE_SUPERCLASS_COLON" value="false" />
</Objective-C>
<Objective-C-extensions>
<extensions>
<pair source="cc" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</Objective-C-extensions>
<Python>
<option name="USE_CONTINUATION_INDENT_FOR_ARGUMENTS" value="true" />
</Python>
<TypeScriptCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</TypeScriptCodeStyleSettings>
<XML>
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<files>
<extensions>
<pair source="cc" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</files>
<codeStyleSettings language="CSS">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
@ -318,9 +315,7 @@
<codeStyleSettings language="protobuf">
<option name="RIGHT_MARGIN" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>

5
.mailmap Normal file
View file

@ -0,0 +1,5 @@
# See: https://git-scm.com/docs/git-shortlog#_mapping_authors
#
Brooke Vibber <bvibber@wikimedia.org>
Brooke Vibber <bvibber@wikimedia.org> <brion@wikimedia.org>
Brooke Vibber <bvibber@wikimedia.org> <brion@pobox.com>

View file

@ -1,5 +1,154 @@
# Wikimedia Commons for Android
## 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

View file

@ -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

View file

@ -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! :-)
<a href="https://f-droid.org/repository/browse/?fdid=fr.free.nrw.commons" target="_blank">
<img src="https://upload.wikimedia.org/wikipedia/commons/archive/9/96/20200131184248%21%22Get_it_on_F-droid%22_Badge.png" alt="Get it on F-Droid" height="90"/></a>
@ -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!
| [<img src="https://avatars.githubusercontent.com/u/3611199?v=4" width="100px;"/><br /><sub><b>misaochan</b></sub>](https://github.com/misaochan) | [<img src="https://avatars.githubusercontent.com/u/24829418?v=4" width="100px;"/><br /><sub><b>translatewiki</b></sub>](https://github.com/translatewiki) | [<img src="https://avatars.githubusercontent.com/u/3127881?v=4" width="100px;"/><br /><sub><b>neslihanturan</b></sub>](https://github.com/neslihanturan) | [<img src="https://avatars.githubusercontent.com/u/30430?v=4" width="100px;"/><br /><sub><b>yuvipanda</b></sub>](https://github.com/yuvipanda) | [<img src="https://avatars.githubusercontent.com/u/99590?v=4" width="100px;"/><br /><sub><b>nicolas-raoul</b></sub>](https://github.com/nicolas-raoul) |
| :---: | :---: | :---: | :---: | :---: |
| [<img src="https://avatars.githubusercontent.com/u/4953590?v=4" width="100px;"/><br /><sub><b>domdomegg</b></sub>](https://github.com/domdomegg) | [<img src="https://avatars.githubusercontent.com/u/3069373?v=4" width="100px;"/><br /><sub><b>maskaravivek</b></sub>](https://github.com/maskaravivek) | [<img src="https://avatars.githubusercontent.com/u/407647?v=4" width="100px;"/><br /><sub><b>psh</b></sub>](https://github.com/psh) | [<img src="https://avatars.githubusercontent.com/u/103075?v=4" width="100px;"/><br /><sub><b>brion</b></sub>](https://github.com/brion) | [<img src="https://avatars.githubusercontent.com/u/17375274?v=4" width="100px;"/><br /><sub><b>ashishkumar468</b></sub>](https://github.com/ashishkumar468) |
| [<img src="https://avatars.githubusercontent.com/u/10674?v=4" width="100px;"/><br /><sub><b>whym</b></sub>](https://github.com/whym) | [<img src="https://avatars.githubusercontent.com/u/10153800?v=4" width="100px;"/><br /><sub><b>akaita</b></sub>](https://github.com/akaita) | [<img src="https://avatars.githubusercontent.com/u/30932899?v=4" width="100px;"/><br /><sub><b>madhurgupta10</b></sub>](https://github.com/madhurgupta10) | [<img src="https://avatars.githubusercontent.com/u/6900601?v=4" width="100px;"/><br /><sub><b>veyndan</b></sub>](https://github.com/veyndan) | [<img src="https://avatars.githubusercontent.com/u/19607555?v=4" width="100px;"/><br /><sub><b>ujjwalagrawal17</b></sub>](https://github.com/ujjwalagrawal17) |
| [<img src="https://avatars.githubusercontent.com/u/3358282?v=4" width="100px;"/><br /><sub><b>macgills</b></sub>](https://github.com/macgills) | [<img src="https://avatars.githubusercontent.com/u/1682214?v=4" width="100px;"/><br /><sub><b>dbrant</b></sub>](https://github.com/dbrant) | [<img src="https://avatars.githubusercontent.com/u/34261945?v=4" width="100px;"/><br /><sub><b>vanshikaarora</b></sub>](https://github.com/vanshikaarora) | [<img src="https://avatars.githubusercontent.com/u/1345681?v=4" width="100px;"/><br /><sub><b>sandarumk</b></sub>](https://github.com/sandarumk) | [<img src="https://avatars.githubusercontent.com/u/29161745?v=4" width="100px;"/><br /><sub><b>tanvidadu</b></sub>](https://github.com/tanvidadu) |
| [<img src="https://avatars.githubusercontent.com/u/39745544?v=4" width="100px;"/><br /><sub><b>cypherop</b></sub>](https://github.com/cypherop) | [<img src="https://avatars.githubusercontent.com/u/6953323?v=4" width="100px;"/><br /><sub><b>tobias47n9e</b></sub>](https://github.com/tobias47n9e) | [<img src="https://avatars.githubusercontent.com/u/25305892?v=4" width="100px;"/><br /><sub><b>hismaeel</b></sub>](https://github.com/hismaeel) | [<img src="https://avatars.githubusercontent.com/u/12574756?v=4" width="100px;"/><br /><sub><b>tshradheya</b></sub>](https://github.com/tshradheya) | [<img src="https://avatars.githubusercontent.com/u/3308769?v=4" width="100px;"/><br /><sub><b>addshore</b></sub>](https://github.com/addshore) |
| [<img src="https://avatars.githubusercontent.com/u/20313518?v=4" width="100px;"/><br /><sub><b>knight-shade</b></sub>](https://github.com/knight-shade) | [<img src="https://avatars.githubusercontent.com/u/210297?v=4" width="100px;"/><br /><sub><b>siebrand</b></sub>](https://github.com/siebrand) | [<img src="https://avatars.githubusercontent.com/u/12448084?v=4" width="100px;"/><br /><sub><b>sivaraam</b></sub>](https://github.com/sivaraam) | [<img src="https://avatars.githubusercontent.com/u/5329780?v=4" width="100px;"/><br /><sub><b>Bluesir9</b></sub>](https://github.com/Bluesir9) | [<img src="https://avatars.githubusercontent.com/u/44129798?v=4" width="100px;"/><br /><sub><b>kbhardwaj123</b></sub>](https://github.com/kbhardwaj123) |
| [<img src="https://avatars.githubusercontent.com/u/4953590?v=4" width="100px;"/><br /><sub><b>domdomegg</b></sub>](https://github.com/domdomegg) | [<img src="https://avatars.githubusercontent.com/u/3069373?v=4" width="100px;"/><br /><sub><b>maskaravivek</b></sub>](https://github.com/maskaravivek) | [<img src="https://avatars.githubusercontent.com/u/407647?v=4" width="100px;"/><br /><sub><b>psh</b></sub>](https://github.com/psh) | [<img src="https://avatars.githubusercontent.com/u/30932899?v=4" width="100px;"/><br /><sub><b>madhurgupta10</b></sub>](https://github.com/madhurgupta10) | [<img src="https://avatars.githubusercontent.com/u/17375274?v=4" width="100px;"/><br /><sub><b>ashishkumar468</b></sub>](https://github.com/ashishkumar468) |
| [<img src="https://avatars.githubusercontent.com/u/103075?v=4" width="100px;"/><br /><sub><b>bvibber</b></sub>](https://github.com/bvibber) | [<img src="https://avatars.githubusercontent.com/u/10674?v=4" width="100px;"/><br /><sub><b>whym</b></sub>](https://github.com/whym) | [<img src="https://avatars.githubusercontent.com/u/10153800?v=4" width="100px;"/><br /><sub><b>akaita</b></sub>](https://github.com/akaita) | [<img src="https://avatars.githubusercontent.com/u/6900601?v=4" width="100px;"/><br /><sub><b>veyndan</b></sub>](https://github.com/veyndan) | [<img src="https://avatars.githubusercontent.com/u/19607555?v=4" width="100px;"/><br /><sub><b>ujjwalagrawal17</b></sub>](https://github.com/ujjwalagrawal17) |
| [<img src="https://avatars.githubusercontent.com/u/3358282?v=4" width="100px;"/><br /><sub><b>macgills</b></sub>](https://github.com/macgills) | [<img src="https://avatars.githubusercontent.com/u/1682214?v=4" width="100px;"/><br /><sub><b>dbrant</b></sub>](https://github.com/dbrant) | [<img src="https://avatars.githubusercontent.com/u/34261945?v=4" width="100px;"/><br /><sub><b>vanshikaarora</b></sub>](https://github.com/vanshikaarora) | [<img src="https://avatars.githubusercontent.com/u/12448084?v=4" width="100px;"/><br /><sub><b>sivaraam</b></sub>](https://github.com/sivaraam) | [<img src="https://avatars.githubusercontent.com/u/71203077?v=4" width="100px;"/><br /><sub><b>Ayan-10</b></sub>](https://github.com/Ayan-10) |
| [<img src="https://avatars.githubusercontent.com/u/126143257?v=4" width="100px;"/><br /><sub><b>shashankiitbhu</b></sub>](https://github.com/shashankiitbhu) | [<img src="https://avatars.githubusercontent.com/u/54663429?v=4" width="100px;"/><br /><sub><b>Pratham2305</b></sub>](https://github.com/Pratham2305) | [<img src="https://avatars.githubusercontent.com/u/1345681?v=4" width="100px;"/><br /><sub><b>sandarumk</b></sub>](https://github.com/sandarumk) | [<img src="https://avatars.githubusercontent.com/u/29161745?v=4" width="100px;"/><br /><sub><b>tanvidadu</b></sub>](https://github.com/tanvidadu) | [<img src="https://avatars.githubusercontent.com/u/39745544?v=4" width="100px;"/><br /><sub><b>cypherop</b></sub>](https://github.com/cypherop) |
| [<img src="https://avatars.githubusercontent.com/u/65972015?v=4" width="100px;"/><br /><sub><b>Prince-kushwaha</b></sub>](https://github.com/Prince-kushwaha) | [<img src="https://avatars.githubusercontent.com/u/6953323?v=4" width="100px;"/><br /><sub><b>tobias47n9e</b></sub>](https://github.com/tobias47n9e) | [<img src="https://avatars.githubusercontent.com/u/54016427?v=4" width="100px;"/><br /><sub><b>4D17Y4</b></sub>](https://github.com/4D17Y4) | [<img src="https://avatars.githubusercontent.com/u/25305892?v=4" width="100px;"/><br /><sub><b>hismaeel</b></sub>](https://github.com/hismaeel) | [<img src="https://avatars.githubusercontent.com/u/12574756?v=4" width="100px;"/><br /><sub><b>tshradheya</b></sub>](https://github.com/tshradheya) |
.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).

View file

@ -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'
@ -45,10 +49,8 @@ dependencies {
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'
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"
@ -73,30 +75,33 @@ dependencies {
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"
// Unit testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.6.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 "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.5.0-alpha04'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0-alpha04'
@ -119,8 +124,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "androidx.exifinterface:exifinterface:1.3.2"
implementation "androidx.core:core-ktx:$CORE_KTX_VERSION"
implementation "androidx.multidex:multidex:2.0.1"
compile 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
//swipe_layout
implementation 'com.daimajia.swipelayout:library:1.2.0@aar'
@ -131,7 +135,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
@ -139,10 +142,12 @@ 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.8.0"
def work_version = "2.8.1"
// Kotlin + coroutines
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation("androidx.work:work-runtime:$work_version")
@ -151,8 +156,21 @@ dependencies {
//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.3") { 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) {
@ -168,17 +186,17 @@ project.gradle.taskGraph.whenReady {
}
android {
compileSdkVersion 31
compileSdkVersion 33
defaultConfig {
//applicationId 'fr.free.nrw.commons'
versionCode 1029
versionName '4.0.3'
versionCode 1040
versionName '5.0.2'
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
minSdkVersion 21
targetSdkVersion 31
targetSdkVersion 33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
@ -188,17 +206,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']
}
}
testOptions {
animationsDisabled true
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests {
returnDefaultValues = true
includeAndroidResources = true
}
unitTests.all {
jvmArgs '-noverify'
@ -223,13 +247,14 @@ 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
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt'
versionNameSuffix "-debug-" + getBranchName()
@ -284,7 +309,6 @@ android {
buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\""
dimension 'tier'
}
@ -320,20 +344,17 @@ android {
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_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "1.8"
}
buildToolsVersion buildToolsVersion
@ -341,7 +362,11 @@ android {
buildFeatures {
viewBinding true
}
namespace 'fr.free.nrw.commons'
lint {
abortOnError false
disable 'MissingTranslation', 'ExtraTranslation'
}
}
String getTestUserName() {
@ -371,7 +396,3 @@ if (isRunningOnTravisAndIsNotPRBuild) {
}
}
}
androidExtensions {
experimental = true
}

View file

@ -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 ---

View file

@ -1,74 +0,0 @@
package fr.free.nrw.commons;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
import android.content.Context;
import androidx.room.Room;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.review.ReviewDao;
import fr.free.nrw.commons.review.ReviewEntity;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class ReviewDaoTest {
private ReviewDao reviewDao;
private AppDatabase database;
/**
* Set up the application database
*/
@Before
public void createDb() {
Context context = ApplicationProvider.getApplicationContext();
database = Room.inMemoryDatabaseBuilder(
context, AppDatabase.class)
.allowMainThreadQueries()
.build();
reviewDao = database.ReviewDao();
}
/**
* Close the database
*/
@After
public void closeDb() {
database.close();
}
/**
* Test insertion
* Also checks isReviewedAlready():
* Case 1: When image has been reviewed/skipped by the user
*/
@Test
public void insert() {
// Insert data
String imageId = "1234";
ReviewEntity reviewEntity = new ReviewEntity(imageId);
reviewDao.insert(reviewEntity);
// Check insertion
// Covers the case where the image exists in the database
// And isReviewedAlready() returns true
Boolean isInserted = reviewDao.isReviewedAlready(imageId);
assertThat(isInserted, equalTo(true));
}
/**
* Test review status of the image
* Case 2: When image has not been reviewed/skipped
*/
@Test
public void isReviewedAlready(){
String imageId = "5856";
Boolean isInserted = reviewDao.isReviewedAlready(imageId);
assertThat(isInserted, equalTo(false));
}
}

View file

@ -234,7 +234,7 @@ class UploadTest {
.actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(0,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description")))
onView(withId(R.id.btn_add_description))
onView(withId(R.id.btn_add))
.perform(click())
onView(withId(R.id.rv_descriptions)).perform(

View file

@ -1,256 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="fr.free.nrw.commons">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" />
<uses-permission android:name="android.permission.SET_WALLPAPER"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" />
<uses-permission android:name="android.permission.SET_WALLPAPER"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<queries>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- Google Maps -->
<package android:name="com.google.android.apps.maps" />
</queries>
<queries>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- Google Maps -->
<package android:name="com.google.android.apps.maps" />
</queries>
<!-- Needed only if your app targets Android 5.0 (API level 21) or higher. -->
<uses-feature android:name="android.hardware.location.gps" />
<!-- Needed only if your app targets Android 5.0 (API level 21) or higher. -->
<uses-feature android:name="android.hardware.location.gps" />
<application
android:name=".CommonsApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/LightAppTheme"
android:largeHeap="true"
android:supportsRtl="true"
tools:replace="android:appComponentFactory"
android:appComponentFactory="commons"
android:requestLegacyExternalStorage = "true"
tools:ignore="GoogleAppIndexingWarning">
<application
android:name=".CommonsApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/LightAppTheme"
android:largeHeap="true"
android:supportsRtl="true"
tools:replace="android:appComponentFactory"
android:appComponentFactory="commons"
android:requestLegacyExternalStorage = "true"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".description.DescriptionEditActivity"
android:exported="true" />
<activity
android:name=".nearby.WikidataFeedback"
android:exported="false" />
<activity android:name="org.acra.dialog.CrashReportDialog"
android:process=":acra"
android:launchMode="singleInstance"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
<activity
android:theme="@style/EditActivityTheme"
android:name=".description.DescriptionEditActivity"
android:exported="true" />
<activity
android:name=".media.ZoomableActivity"
android:label="Zoomable Activity"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" />
<activity
android:name=".edit.EditActivity"
android:exported="false" />
<activity android:name=".auth.LoginActivity"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
<activity android:name="org.acra.dialog.CrashReportDialog"
android:process=":acra"
android:launchMode="singleInstance"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
<action android:name="android.intent.action.MAIN" />
</intent-filter>
<activity
android:name=".media.ZoomableActivity"
android:label="Zoomable Activity"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" />
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<activity android:name=".auth.LoginActivity"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</activity>
<activity android:name=".WelcomeActivity" />
<action android:name="android.intent.action.MAIN" />
</intent-filter>
<activity
android:hardwareAccelerated="false"
android:name=".upload.UploadActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
>
<intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND" />
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<category android:name="android.intent.category.DEFAULT" />
</activity>
<activity android:name=".WelcomeActivity" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/ogg" />
</intent-filter>
<intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<activity
android:hardwareAccelerated="false"
android:name=".upload.UploadActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
>
<intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/ogg" />
</intent-filter>
</activity>
<activity
android:name=".contributions.MainActivity"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:configChanges="screenSize|keyboard|orientation" />
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings" />
<activity
android:name=".AboutActivity"
android:label="@string/title_activity_about"
android:parentActivityName=".contributions.MainActivity" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/ogg" />
</intent-filter>
<intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<activity
android:name=".auth.SignupActivity"
android:configChanges="orientation|screenLayout|screenSize"
android:label="@string/title_activity_signup" />
<category android:name="android.intent.category.DEFAULT" />
<activity
android:name=".notification.NotificationActivity"
android:label="@string/navigation_item_notification" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/ogg" />
</intent-filter>
</activity>
<activity
android:name=".contributions.MainActivity"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:configChanges="screenSize|keyboard|orientation" />
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings" />
<activity
android:name=".AboutActivity"
android:label="@string/title_activity_about"
android:parentActivityName=".contributions.MainActivity" />
<activity android:name=".quiz.QuizActivity"
android:label="@string/quiz"/>
<activity
android:name=".auth.SignupActivity"
android:configChanges="orientation|screenLayout|screenSize"
android:label="@string/title_activity_signup" />
<activity android:name=".quiz.QuizResultActivity"
android:label="@string/result"/>
<activity
android:name=".notification.NotificationActivity"
android:label="@string/navigation_item_notification" />
<activity
android:name=".customselector.ui.selector.CustomSelectorActivity"
android:label="@string/title_activity_custom_selector"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity android:name=".quiz.QuizActivity"
android:label="@string/quiz"/>
<activity
android:name=".category.CategoryDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity android:name=".quiz.QuizResultActivity"
android:label="@string/result"/>
<activity
android:name=".explore.depictions.WikidataItemDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".customselector.ui.selector.CustomSelectorActivity"
android:label="@string/title_activity_custom_selector"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".explore.SearchActivity"
android:label="@string/title_activity_search"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|screenSize"
android:parentActivityName=".contributions.MainActivity"
/>
<activity
android:name=".category.CategoryDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".profile.ProfileActivity"
android:configChanges="orientation|screenSize|keyboard"
android:label="@string/Profile" />
<activity
android:name=".explore.depictions.WikidataItemDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".review.ReviewActivity"
android:label="@string/title_activity_review" />
<activity
android:name=".explore.SearchActivity"
android:label="@string/title_activity_search"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|screenSize"
android:parentActivityName=".contributions.MainActivity"
/>
<activity
android:name=".LocationPicker.LocationPickerActivity"
android:label="Location Picker" />
<activity
android:name=".profile.ProfileActivity"
android:configChanges="orientation|screenSize|keyboard"
android:label="@string/Profile" />
<service
android:name=".auth.WikiAccountAuthenticatorService"
android:exported="true"
android:process=":auth">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<activity
android:name=".review.ReviewActivity"
android:label="@string/title_activity_review" />
<service
android:name="org.acra.sender.SenderService"
android:exported="false"
android:process=":acra" />
<activity
android:name=".LocationPicker.LocationPickerActivity"
android:label="Location Picker" />
<provider
android:name=".filepicker.ExtendedFileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<service
android:name=".auth.WikiAccountAuthenticatorService"
android:exported="true"
android:process=":auth">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<provider
android:name=".category.CategoryContentProvider"
android:authorities="${applicationId}.categories.contentprovider"
android:exported="false"
android:label="@string/provider_categories"
android:syncable="false" />
<service
android:name="org.acra.sender.SenderService"
android:exported="false"
android:process=":acra" />
<provider
android:name=".explore.recentsearches.RecentSearchesContentProvider"
android:authorities="${applicationId}.explore.recentsearches.contentprovider"
android:exported="false"
android:label="@string/provider_searches"
android:syncable="false" />
<provider
android:name=".filepicker.ExtendedFileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name=".recentlanguages.RecentLanguagesContentProvider"
android:authorities="${applicationId}.recentlanguages.contentprovider"
android:exported="false"
android:label="@string/provider_recent_languages"
android:syncable="false" />
<provider
android:name=".category.CategoryContentProvider"
android:authorities="${applicationId}.categories.contentprovider"
android:exported="false"
android:label="@string/provider_categories"
android:syncable="false" />
<provider
android:name=".bookmarks.pictures.BookmarkPicturesContentProvider"
android:authorities="${applicationId}.bookmarks.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks"
android:syncable="false" />
<provider
android:name=".explore.recentsearches.RecentSearchesContentProvider"
android:authorities="${applicationId}.explore.recentsearches.contentprovider"
android:exported="false"
android:label="@string/provider_searches"
android:syncable="false" />
<provider
android:name=".bookmarks.locations.BookmarkLocationsContentProvider"
android:authorities="${applicationId}.bookmarks.locations.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<provider
android:name=".recentlanguages.RecentLanguagesContentProvider"
android:authorities="${applicationId}.recentlanguages.contentprovider"
android:exported="false"
android:label="@string/provider_recent_languages"
android:syncable="false" />
<provider
android:name=".bookmarks.items.BookmarkItemsContentProvider"
android:authorities="${applicationId}.bookmarks.items.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<provider
android:name=".bookmarks.pictures.BookmarkPicturesContentProvider"
android:authorities="${applicationId}.bookmarks.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks"
android:syncable="false" />
<receiver android:name=".widget.PicOfDayAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<provider
android:name=".bookmarks.locations.BookmarkLocationsContentProvider"
android:authorities="${applicationId}.bookmarks.locations.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/pic_of_day_app_widget_info" />
</receiver>
<provider
android:name=".bookmarks.items.BookmarkItemsContentProvider"
android:authorities="${applicationId}.bookmarks.items.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<uses-library android:name="org.apache.http.legacy" android:required="false" />
<receiver android:name=".widget.PicOfDayAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
</application>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/pic_of_day_app_widget_info" />
</receiver>
</manifest>
<uses-library android:name="org.apache.http.legacy" android:required="false" />
</application>
</manifest>

View file

@ -0,0 +1,64 @@
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 as BitmapDrawable).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
}
}
}

View file

@ -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 {
return 0
}
companion object CREATOR : Parcelable.Creator<CameraPosition> {
override fun createFromParcel(parcel: Parcel): CameraPosition {
return CameraPosition(parcel)
}
override fun newArray(size: Int): Array<CameraPosition?> {
return arrayOfNulls(size)
}
}
}

View file

@ -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;
}
}

View file

@ -9,22 +9,22 @@ import static org.acra.ReportField.STACK_TRACE;
import static org.acra.ReportField.USER_COMMENT;
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.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.mapbox.mapboxsdk.WellKnownTileServer;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao.Table;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
@ -36,12 +36,14 @@ 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;
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar;
import io.reactivex.Completable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.internal.functions.Functions;
@ -59,8 +61,6 @@ 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(
@ -85,6 +85,9 @@ import timber.log.Timber;
public class CommonsApplication extends MultiDexApplication {
public static final String loginMessageIntentKey = "loginMessage";
public static final String loginUsernameIntentKey = "loginUsername";
public static final String IS_LIMITED_CONNECTION_MODE_ENABLED = "is_limited_connection_mode_enabled";
@Inject
SessionManager sessionManager;
@ -95,6 +98,9 @@ public class CommonsApplication extends MultiDexApplication {
@Named("default_preferences")
JsonKvStore defaultPrefs;
@Inject
CommonsCookieJar cookieJar;
@Inject
CustomOkHttpNetworkFetcher customOkHttpNetworkFetcher;
@ -137,10 +143,15 @@ public class CommonsApplication extends MultiDexApplication {
ContributionDao contributionDao;
/**
* In-memory list of contributions whose uploads have been paused by the user
* In-memory list of contributions whose uploads have been paused by the user
*/
public static Map<String, Boolean> pauseUploads = new HashMap<>();
/**
* In-memory list of uploads that have been cancelled by the user
*/
public static HashSet<String> cancelledUploads = new HashSet<>();
/**
* Used to declare and initialize various components and dependencies
*/
@ -150,15 +161,12 @@ public class CommonsApplication extends MultiDexApplication {
INSTANCE = this;
ACRA.init(this);
Mapbox.getInstance(this, getString(R.string.mapbox_commons_app_token), WellKnownTileServer.Mapbox);
ApplicationlessInjection
.getInstance(this)
.getCommonsApplicationComponent()
.inject(this);
AppAdapter.set(new CommonsAppAdapter(sessionManager, defaultPrefs));
initTimber();
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
@ -286,6 +294,7 @@ public class CommonsApplication extends MultiDexApplication {
}
sessionManager.logout()
.andThen(Completable.fromAction(() -> cookieJar.clear()))
.andThen(Completable.fromAction(() -> {
Timber.d("All accounts have been removed");
clearImageCache();
@ -337,4 +346,96 @@ public class CommonsApplication extends MultiDexApplication {
void 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.
*/
public static class BaseLogoutListener implements CommonsApplication.LogoutListener {
Context ctx;
String loginMessage, userName;
/**
* Constructor for BaseLogoutListener.
*
* @param ctx Application context
*/
public BaseLogoutListener(final Context ctx) {
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
*/
public BaseLogoutListener(final Context ctx, final String loginMessage,
final String loginUsername) {
this.ctx = ctx;
this.loginMessage = loginMessage;
this.userName = loginUsername;
}
@Override
public void onLogoutComplete() {
Timber.d("Logout complete callback received.");
final Intent loginIntent = new Intent(ctx, LoginActivity.class);
loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (loginMessage != null) {
loginIntent.putExtra(loginMessageIntentKey, loginMessage);
}
if (userName != null) {
loginIntent.putExtra(loginUsernameIntentKey, 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.
*/
public static class ActivityLogoutListener extends BaseLogoutListener {
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.
*/
public ActivityLogoutListener(final Activity activity, final Context ctx) {
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.
*/
public ActivityLogoutListener(final Activity activity, final Context ctx,
final String loginMessage, final String loginUsername) {
super(activity, loginMessage, loginUsername);
this.activity = activity;
}
@Override
public void onLogoutComplete() {
super.onLogoutComplete();
activity.finish();
}
}
}

View file

@ -2,7 +2,8 @@ package fr.free.nrw.commons.LocationPicker;
import android.app.Activity;
import android.content.Intent;
import com.mapbox.mapboxsdk.camera.CameraPosition;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.Media;
/**
* Helper class for starting the activity
@ -52,6 +53,17 @@ public final class LocationPicker {
return this;
}
/**
* Gets and puts media in intent
* @param media Media
* @return LocationPicker.IntentBuilder
*/
public LocationPicker.IntentBuilder media(
final Media media) {
intent.putExtra(LocationPickerConstants.MEDIA, media);
return this;
}
/**
* Gets and sets the activity
* @param activity Activity

View file

@ -1,20 +1,22 @@
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 static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.location.Location;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.location.LocationManager;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.Html;
import android.text.method.LinkMovementMethod;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.animation.OvershootInterpolator;
@ -28,52 +30,56 @@ 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 androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
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.engine.LocationEngineCallback;
import com.mapbox.mapboxsdk.location.engine.LocationEngineResult;
import com.mapbox.mapboxsdk.location.modes.CameraMode;
import com.mapbox.mapboxsdk.location.modes.RenderMode;
import com.mapbox.mapboxsdk.location.permissions.PermissionsManager;
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.MapStyle;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.coordinates.CoordinateEditHelper;
import fr.free.nrw.commons.filepicker.Constants;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import org.jetbrains.annotations.NotNull;
import org.osmdroid.tileprovider.tilesource.TileSourceFactory;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.util.constants.GeoConstants;
import org.osmdroid.views.CustomZoomButtonsController;
import org.osmdroid.views.overlay.Marker;
import org.osmdroid.views.overlay.Overlay;
import org.osmdroid.views.overlay.ScaleDiskOverlay;
import org.osmdroid.views.overlay.TilesOverlay;
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<CameraPosition> {
public class LocationPickerActivity extends BaseActivity implements
LocationPermissionCallback {
/**
* DROPPED_MARKER_LAYER_ID : id for layer
* coordinateEditHelper: helps to edit coordinates
*/
private static final String DROPPED_MARKER_LAYER_ID = "DROPPED_MARKER_LAYER_ID";
@Inject
CoordinateEditHelper coordinateEditHelper;
/**
* media : Media object
*/
private Media media;
/**
* cameraPosition : position of picker
*/
@ -83,13 +89,9 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
*/
private ImageView markerImage;
/**
* mapboxMap : map
* mapView : OSM Map
*/
private MapboxMap mapboxMap;
/**
* mapView : view of the map
*/
private MapView mapView;
private org.osmdroid.views.MapView mapView;
/**
* tvAttribution : credit
*/
@ -98,14 +100,14 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
* activity : activity key
*/
private String activity;
/**
* location : location
*/
private Location location;
/**
* modifyLocationButton : button for start editing location
*/
Button modifyLocationButton;
/**
* removeLocationButton : button to remove location metadata
*/
Button removeLocationButton;
/**
* showInMapButton : button for showing in map
*/
@ -118,10 +120,6 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
* fabCenterOnLocation: button for center on location;
*/
FloatingActionButton fabCenterOnLocation;
/**
* droppedMarkerLayer : Layer for static screen
*/
private Layer droppedMarkerLayer;
/**
* shadow : imageview of shadow
*/
@ -141,19 +139,38 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
@Named("default_preferences")
public
JsonKvStore applicationKvStore;
BasicKvStore store;
/**
* isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly
*/
@Inject
SystemThemeUtils systemThemeUtils;
private boolean isDarkTheme;
private boolean moveToCurrentLocation;
@Inject
LocationServiceManager locationManager;
LocationPermissionsHelper locationPermissionsHelper;
@Inject
SessionManager sessionManager;
/**
* Constants
*/
private static final String CAMERA_POS = "cameraPosition";
private static final String ACTIVITY = "activity";
@SuppressLint("ClickableViewAccessibility")
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
super.onCreate(savedInstanceState);
isDarkTheme = systemThemeUtils.isDeviceInNightMode();
moveToCurrentLocation = false;
store = new BasicKvStore(this, "LocationPermissions");
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
final ActionBar actionBar = getSupportActionBar();
@ -166,12 +183,12 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
cameraPosition = getIntent()
.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY);
media = getIntent().getParcelableExtra(LocationPickerConstants.MEDIA);
}else{
cameraPosition = savedInstanceState.getParcelable(CAMERA_POS);
activity = savedInstanceState.getString(ACTIVITY);
media = savedInstanceState.getParcelable("sMedia");
}
final LocationPickerViewModel viewModel = new ViewModelProvider(this)
.get(LocationPickerViewModel.class);
viewModel.getResult().observe(this, this);
bindViews();
addBackButtonListener();
addPlaceSelectedButton();
@ -179,18 +196,57 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
getToolbarUI();
addCenterOnGPSButton();
org.osmdroid.config.Configuration.getInstance().load(getApplicationContext(),
PreferenceManager.getDefaultSharedPreferences(getApplicationContext()));
mapView.setTileSource(TileSourceFactory.WIKIMEDIA);
mapView.setTilesScaledToDpi(true);
mapView.setMultiTouchControls(true);
org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put(
"Referer", "http://maps.wikimedia.org/"
);
mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER);
mapView.getController().setZoom(ZOOM_LEVEL);
mapView.setOnTouchListener((v, event) -> {
if (event.getAction() == MotionEvent.ACTION_MOVE) {
if (markerImage.getTranslationY() == 0) {
markerImage.animate().translationY(-75)
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
}
} else if (event.getAction() == MotionEvent.ACTION_UP) {
markerImage.animate().translationY(0)
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
}
return false;
});
if ("UploadActivity".equals(activity)) {
placeSelectedButton.setVisibility(View.GONE);
modifyLocationButton.setVisibility(View.VISIBLE);
removeLocationButton.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));
fabCenterOnLocation.setVisibility(View.GONE);
markerImage.setVisibility(View.GONE);
shadow.setVisibility(View.GONE);
assert cameraPosition != null;
showSelectedLocationMarker(new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude()));
}
setupMapView();
if("UploadActivity".equals(activity)){
if(mapView != null && mapView.getController() != null && cameraPosition != null){
GeoPoint cameraGeoPoint = new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude());
mapView.onCreate(savedInstanceState);
mapView.getMapAsync(this);
mapView.getController().setCenter(cameraGeoPoint);
mapView.getController().animateTo(cameraGeoPoint);
}
}
}
/**
@ -201,12 +257,26 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* For setting up Dark Theme
*/
private void darkThemeSetup() {
if (isDarkTheme) {
shadow.setColorFilter(Color.argb(255, 255, 255, 255));
mapView.getOverlayManager().getTilesOverlay()
.setColorFilter(TilesOverlay.INVERT_COLORS);
}
}
/**
* Clicking back button destroy locationPickerActivity
*/
private void addBackButtonListener() {
final ImageView backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button);
backButton.setOnClickListener(view -> finish());
backButton.setOnClickListener(v -> {
finish();
});
}
/**
@ -217,21 +287,12 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
markerImage = findViewById(R.id.location_picker_image_view_marker);
tvAttribution = findViewById(R.id.tv_attribution);
modifyLocationButton = findViewById(R.id.modify_location);
removeLocationButton = findViewById(R.id.remove_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
*/
@ -242,49 +303,13 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
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(isDarkTheme ? MapStyle.DARK : MapStyle.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();
}
private void setupMapView() {
adjustCameraBasedOnOptions();
modifyLocationButton.setOnClickListener(v -> onClickModifyLocation());
removeLocationButton.setOnClickListener(v -> onClickRemoveLocation());
showInMapButton.setOnClickListener(v -> showInMap());
darkThemeSetup();
requestLocationPermissions();
}
/**
@ -293,133 +318,70 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
private void onClickModifyLocation() {
placeSelectedButton.setVisibility(View.VISIBLE);
modifyLocationButton.setVisibility(View.GONE);
removeLocationButton.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();
fabCenterOnLocation.setVisibility(View.VISIBLE);
removeSelectedLocationMarker();
if (cameraPosition != null && mapView != null) {
if (mapView.getController() != null) {
mapView.getController().animateTo(new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude()));
}
}
}
/**
* Handles onclick event of removeLocationButton
*/
private void onClickRemoveLocation() {
DialogUtil.showAlertDialog(this,
getString(R.string.remove_location_warning_title),
getString(R.string.remove_location_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel), () -> removeLocationFromImage(), null);
}
/**
* Method to remove the location from the picture
*/
private void removeLocationFromImage() {
if (media != null) {
compositeDisposable.add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext()
, media, "0.0", "0.0", "0.0f")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(s -> {
Timber.d("Coordinates are removed from the image");
}));
}
final Intent returningIntent = new Intent();
setResult(AppCompatActivity.RESULT_OK, returningIntent);
finish();
}
/**
* Show the location in map app
*/
public void showInMap(){
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)
));
new fr.free.nrw.commons.location.LatLng(mapView.getMapCenter().getLatitude(),
mapView.getMapCenter().getLongitude(), 0.0f));
}
/**
* 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);
// Get the component's location engine to receive user's last location
locationComponent.getLocationEngine().getLastLocation(
new LocationEngineCallback<LocationEngineResult>() {
@Override
public void onSuccess(LocationEngineResult result) {
location = result.getLastLocation();
}
@Override
public void onFailure(@NonNull Exception exception) {
}
});
if (cameraPosition != null) {
mapView.getController().setCenter(new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude()));
}
}
/**
* 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
*/
@ -434,35 +396,127 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
void placeSelected() {
if (activity.equals("NoLocationUploadActivity")) {
applicationKvStore.putString(LAST_LOCATION,
mapboxMap.getCameraPosition().target.getLatitude()
mapView.getMapCenter().getLatitude()
+ ","
+ mapboxMap.getCameraPosition().target.getLongitude());
applicationKvStore.putString(LAST_ZOOM, mapboxMap.getCameraPosition().zoom + "");
+ mapView.getMapCenter().getLongitude());
applicationKvStore.putString(LAST_ZOOM, mapView.getZoomLevel() + "");
}
final Intent returningIntent = new Intent();
returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
mapboxMap.getCameraPosition());
setResult(AppCompatActivity.RESULT_OK, returningIntent);
if (media == null) {
final Intent returningIntent = new Intent();
returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
new CameraPosition(mapView.getMapCenter().getLatitude(),
mapView.getMapCenter().getLongitude(), 14.0));
setResult(AppCompatActivity.RESULT_OK, returningIntent);
} else {
updateCoordinates(String.valueOf(mapView.getMapCenter().getLatitude()),
String.valueOf(mapView.getMapCenter().getLongitude()),
String.valueOf(0.0f));
}
finish();
}
/**
* Fetched coordinates are replaced with existing coordinates by a POST API call.
* @param Latitude to be added
* @param Longitude to be added
* @param Accuracy to be added
*/
public void updateCoordinates(final String Latitude, final String Longitude,
final String Accuracy) {
if (media == null) {
return;
}
try {
compositeDisposable.add(
coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media,
Latitude, Longitude, Accuracy)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(s -> {
Timber.d("Coordinates are added.");
}));
} catch (Exception e) {
if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) {
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
this, logoutListener);
}
}
}
/**
* Center the camera on the last saved location
*/
private void addCenterOnGPSButton(){
private void addCenterOnGPSButton() {
fabCenterOnLocation = findViewById(R.id.center_on_gps);
fabCenterOnLocation.setOnClickListener(view -> getCenter());
fabCenterOnLocation.setOnClickListener(view -> {
moveToCurrentLocation = true;
requestLocationPermissions();
});
}
/**
* Animate map to move to desired Latitude and Longitude
* Adds selected location marker on the map
*/
void getCenter() {
mapboxMap.animateCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(location.getLatitude(),location.getLongitude()),15.0));
private void showSelectedLocationMarker(GeoPoint point) {
Drawable icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker);
Marker marker = new Marker(mapView);
marker.setPosition(point);
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM);
marker.setIcon(icon);
marker.setInfoWindow(null);
mapView.getOverlays().add(marker);
mapView.invalidate();
}
/**
* Removes selected location marker from the map
*/
private void removeSelectedLocationMarker() {
List<Overlay> overlays = mapView.getOverlays();
for (int i = 0; i < overlays.size(); i++) {
if (overlays.get(i) instanceof Marker) {
Marker item = (Marker) overlays.get(i);
if (cameraPosition.getLatitude() == item.getPosition().getLatitude()
&& cameraPosition.getLongitude() == item.getPosition().getLongitude()) {
mapView.getOverlays().remove(i);
mapView.invalidate();
break;
}
}
}
}
/**
* Center the map at user's current location
*/
private void requestLocationPermissions() {
locationPermissionsHelper = new LocationPermissionsHelper(
this, locationManager, this);
locationPermissionsHelper.requestForLocationAccess(R.string.location_permission_title,
R.string.upload_map_location_access);
}
@Override
protected void onStart() {
super.onStart();
mapView.onStart();
public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
if (requestCode == Constants.RequestCodes.LOCATION
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
onLocationPermissionGranted();
} else {
onLocationPermissionDenied(getString(R.string.upload_map_location_access));
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@Override
@ -478,26 +532,102 @@ public class LocationPickerActivity extends BaseActivity implements OnMapReadyCa
}
@Override
protected void onStop() {
super.onStop();
mapView.onStop();
public void onLocationPermissionDenied(String toastMessage) {
if (!ActivityCompat.shouldShowRequestPermissionRationale(this,
permission.ACCESS_FINE_LOCATION)) {
if (!locationPermissionsHelper.checkLocationPermission(this)) {
if (store.getBoolean("isPermissionDenied", false)) {
// means user has denied location permission twice or checked the "Don't show again"
locationPermissionsHelper.showAppSettingsDialog(this,
R.string.upload_map_location_access);
} else {
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
}
store.putBoolean("isPermissionDenied", true);
}
} else {
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
}
}
@Override
protected void onSaveInstanceState(final @NotNull Bundle outState) {
public void onLocationPermissionGranted() {
if (moveToCurrentLocation || !(activity.equals("MediaActivity"))) {
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
locationManager.requestLocationUpdatesFromProvider(
LocationManager.NETWORK_PROVIDER);
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
getLocation();
} else {
getLocation();
locationPermissionsHelper.showLocationOffDialog(this,
R.string.ask_to_turn_location_on_text);
}
}
}
/**
* Gets new location if locations services are on, else gets last location
*/
private void getLocation() {
fr.free.nrw.commons.location.LatLng currLocation = locationManager.getLastLocation();
if (currLocation != null) {
GeoPoint currLocationGeopoint = new GeoPoint(currLocation.getLatitude(),
currLocation.getLongitude());
addLocationMarker(currLocationGeopoint);
mapView.getController().setCenter(currLocationGeopoint);
mapView.getController().animateTo(currLocationGeopoint);
markerImage.setTranslationY(0);
}
}
private void addLocationMarker(GeoPoint geoPoint) {
if (moveToCurrentLocation) {
mapView.getOverlays().clear();
}
ScaleDiskOverlay diskOverlay =
new ScaleDiskOverlay(this,
geoPoint, 2000, GeoConstants.UnitOfMeasure.foot);
Paint circlePaint = new Paint();
circlePaint.setColor(Color.rgb(128, 128, 128));
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(2f);
diskOverlay.setCirclePaint2(circlePaint);
Paint diskPaint = new Paint();
diskPaint.setColor(Color.argb(40, 128, 128, 128));
diskPaint.setStyle(Paint.Style.FILL_AND_STROKE);
diskOverlay.setCirclePaint1(diskPaint);
diskOverlay.setDisplaySizeMin(900);
diskOverlay.setDisplaySizeMax(1700);
mapView.getOverlays().add(diskOverlay);
org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker(
mapView);
startMarker.setPosition(geoPoint);
startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER,
org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM);
startMarker.setIcon(
ContextCompat.getDrawable(this, R.drawable.current_location_marker));
startMarker.setTitle("Your Location");
startMarker.setTextLabelFontSize(24);
mapView.getOverlays().add(startMarker);
}
/**
* Saves the state of the activity
* @param outState Bundle
*/
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
mapView.onSaveInstanceState(outState);
}
if(cameraPosition!=null){
outState.putParcelable(CAMERA_POS, cameraPosition);
}
if(activity!=null){
outState.putString(ACTIVITY, activity);
}
@Override
protected void onDestroy() {
super.onDestroy();
mapView.onDestroy();
}
@Override
public void onLowMemory() {
super.onLowMemory();
mapView.onLowMemory();
if(media!=null){
outState.putParcelable("sMedia", media);
}
}
}

View file

@ -1,7 +1,5 @@
package fr.free.nrw.commons.LocationPicker;
import com.mapbox.mapboxsdk.maps.Style;
/**
* Constants need for location picking
*/
@ -13,6 +11,9 @@ public final class LocationPickerConstants {
public static final String MAP_CAMERA_POSITION
= "location.picker.cameraPosition";
public static final String MEDIA
= "location.picker.media";
private LocationPickerConstants() {
}

View file

@ -4,7 +4,7 @@ import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.MutableLiveData;
import com.mapbox.mapboxsdk.camera.CameraPosition;
import fr.free.nrw.commons.CameraPosition;
import org.jetbrains.annotations.NotNull;
import retrofit2.Call;
import retrofit2.Callback;

View file

@ -12,7 +12,7 @@ public abstract class MapController {
public class NearbyPlacesInfo {
public List<Place> placeList; // List of nearby places
public LatLng[] boundaryCoordinates; // Corners of nearby area
public LatLng curLatLng; // Current location when this places are populated
public LatLng currentLatLng; // Current location when this places are populated
public LatLng searchLatLng; // Search location for finding this places
public List<Media> mediaList; // Search location for finding this places
}
@ -23,7 +23,7 @@ public abstract class MapController {
public class ExplorePlacesInfo {
public List<Place> explorePlaceList; // List of nearby places
public LatLng[] boundaryCoordinates; // Corners of nearby area
public LatLng curLatLng; // Current location when this places are populated
public LatLng currentLatLng; // Current location when this places are populated
public LatLng searchLatLng; // Search location for finding this places
public List<Media> mediaList; // Search location for finding this places
}

View file

@ -1,12 +0,0 @@
package fr.free.nrw.commons;
import com.mapbox.mapboxsdk.maps.Style;
/**
* Constants for various map styles
*/
public final class MapStyle {
public static final String DARK = Style.getPredefinedStyle("Dark");
public static final String OUTDOORS = Style.getPredefinedStyle("Outdoors");
public static final String STREETS = Style.getPredefinedStyle("Streets");
}

View file

@ -2,9 +2,8 @@ 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 kotlinx.parcelize.Parcelize
import fr.free.nrw.commons.wikidata.model.page.PageTitle
import java.util.*
@Parcelize

View file

@ -43,7 +43,7 @@ class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClien
return Single.ambArray(
mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId)
.onErrorResumeNext { Single.never() },
mediaClient.getMedia(media.filename)
mediaClient.getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() }
)

View file

@ -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<String> 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;
}
}
}

View file

@ -10,18 +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;
@ -31,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) {
@ -137,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))

View file

@ -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;

View file

@ -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
}
}

View file

@ -1,8 +1,10 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import io.reactivex.Observable
import io.reactivex.Single
import org.wikipedia.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import timber.log.Timber
/**
* This class acts as a Client to facilitate wiki page editing
@ -25,10 +27,48 @@ class PageEditClient(
*/
fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> {
return try {
pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.tokenBlocking)
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
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<Boolean> {
return 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)
}
}
}
@ -41,10 +81,14 @@ class PageEditClient(
*/
fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> {
return try {
pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.tokenBlocking)
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)
}
}
}
@ -57,13 +101,40 @@ class PageEditClient(
*/
fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> {
return try {
pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.tokenBlocking)
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()?.editSucceeded() ?: false }
} catch (throwable: Throwable) {
Observable.just(false)
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<Boolean> {
return try {
pageEditInterface.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
}
/**
* Set new labels to Wikibase server of commons
* @param summary Edit summary
@ -76,9 +147,14 @@ class PageEditClient(
language: String, value: String) : Observable<Int>{
return try {
pageEditInterface.postCaptions(summary, title, language,
value, csrfTokenClient.tokenBlocking).map { it.success }
value, csrfTokenClient.getTokenBlocking()
).map { it.success }
} catch (throwable: Throwable) {
Observable.just(0)
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(0)
}
}
}
@ -92,4 +168,4 @@ class PageEditClient(
it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content()
}
}
}
}

View file

@ -1,11 +1,11 @@
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 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 fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import retrofit2.http.*
/**
@ -27,7 +27,7 @@ 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,
@ -36,6 +36,33 @@ interface PageEditInterface {
@Field("token") token: String
): Observable<Edit>
/**
* 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<Edit>
/**
* This method posts such that the Content which the page
* has will be appended with the value being passed to the
@ -47,7 +74,7 @@ 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,
@ -66,7 +93,7 @@ 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,
@ -74,10 +101,20 @@ interface PageEditInterface {
@Field("token") token: String
): Observable<Edit>
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit&section=new")
fun postNewSection(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("sectiontitle") sectionTitle: String,
@Field("text") sectionText: String,
@Field("token") token: String
): Observable<Edit>
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(Service.MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
@POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
fun postCaptions(
@Field("summary") summary: String,
@Field("title") title: String,
@ -91,10 +128,7 @@ interface PageEditInterface {
* @param titles : Name of the file
* @return Single<MwQueryResult>
*/
@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
): Single<MwQueryResponse?>

View file

@ -3,9 +3,9 @@ package fr.free.nrw.commons.actions
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF
import io.reactivex.Observable
import org.wikipedia.csrf.CsrfTokenClient
import org.wikipedia.dataclient.Service
import org.wikipedia.dataclient.mwapi.MwPostResponse
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.auth.login.LoginFailedException
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@ -17,7 +17,7 @@ import javax.inject.Singleton
@Singleton
class ThanksClient @Inject constructor(
@param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient,
@param:Named("commons-service") private val service: Service
private val service: ThanksInterface
) {
/**
* Thanks a user for a particular revision
@ -26,11 +26,23 @@ class ThanksClient @Inject constructor(
*/
fun thank(revisionId: Long): Observable<Boolean> {
return try {
service.thank(revisionId.toString(), null, csrfTokenClient.tokenBlocking, CommonsApplication.getInstance().userAgent)
.map { mwThankPostResponse -> mwThankPostResponse.result.success== 1 }
} catch (throwable: Throwable) {
Observable.just(false)
service.thank(
revisionId.toString(), // Rev
null, // Log
csrfTokenClient.getTokenBlocking(), // Token
CommonsApplication.getInstance().userAgent // Source
).map {
mwThankPostResponse -> mwThankPostResponse.result?.success == 1
}
}
catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
Observable.error(throwable)
}
else {
Observable.just(false)
}
}
}
}
}

View file

@ -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<MwThankPostResponse?>
}

View file

@ -14,10 +14,8 @@ 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 android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -26,31 +24,20 @@ 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.auth.login.LoginClient;
import fr.free.nrw.commons.auth.login.LoginResult;
import fr.free.nrw.commons.databinding.ActivityLoginBinding;
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 fr.free.nrw.commons.auth.login.LoginCallback;
import java.util.Objects;
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;
@ -58,25 +45,19 @@ 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;
import static fr.free.nrw.commons.CommonsApplication.loginMessageIntentKey;
import static fr.free.nrw.commons.CommonsApplication.loginUsernameIntentKey;
public class LoginActivity extends AccountAuthenticatorActivity {
@Inject
SessionManager sessionManager;
@Inject
@Named(NAMED_COMMONS_WIKI_SITE)
WikiSite commonsWikiSite;
@Inject
@Named("default_preferences")
JsonKvStore applicationKvStore;
@ -87,39 +68,16 @@ public class LoginActivity extends AccountAuthenticatorActivity {
@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;
private ActivityLoginBinding binding;
ProgressDialog progressDialog;
private AppCompatDelegate delegate;
private LoginTextWatcher textWatcher = new LoginTextWatcher();
private CompositeDisposable compositeDisposable = new CompositeDisposable();
private Call<MwQueryResponse> 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);
@ -133,31 +91,50 @@ public class LoginActivity extends AccountAuthenticatorActivity {
getDelegate().installViewFactory();
getDelegate().onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
binding = ActivityLoginBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ButterKnife.bind(this);
String message = getIntent().getStringExtra(loginMessageIntentKey);
String username = getIntent().getStringExtra(loginUsernameIntentKey);
usernameEdit.addTextChangedListener(textWatcher);
passwordEdit.addTextChangedListener(textWatcher);
twoFactorEdit.addTextChangedListener(textWatcher);
binding.loginUsername.addTextChangedListener(textWatcher);
binding.loginPassword.addTextChangedListener(textWatcher);
binding.loginTwoFactor.addTextChangedListener(textWatcher);
binding.skipLogin.setOnClickListener(view -> skipLogin());
binding.forgotPassword.setOnClickListener(view -> forgotPassword());
binding.aboutPrivacyPolicy.setOnClickListener(view -> onPrivacyPolicyClicked());
binding.signUpButton.setOnClickListener(view -> signUp());
binding.loginButton.setOnClickListener(view -> performLogin());
binding.loginPassword.setOnEditorActionListener(this::onEditorAction);
binding.loginPassword.setOnFocusChangeListener(this::onPasswordFocusChanged);
if (ConfigUtils.isBetaFlavour()) {
loginCredentials.setText(getString(R.string.login_credential));
binding.loginCredentials.setText(getString(R.string.login_credential));
} else {
loginCredentials.setVisibility(View.GONE);
binding.loginCredentials.setVisibility(View.GONE);
}
if (message != null) {
showMessage(message, R.color.secondaryDarkColor);
}
if (username != null) {
binding.loginUsername.setText(username);
}
}
@OnFocusChange(R.id.login_password)
/**
* 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
*/
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()) {
boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
if (binding.loginButton.isEnabled()) {
if (actionId == IME_ACTION_DONE) {
performLogin();
return true;
@ -170,8 +147,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
@OnClick(R.id.skip_login)
void skipLogin() {
protected void skipLogin() {
new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
.setMessage(R.string.skip_login_message)
.setCancelable(false)
@ -183,18 +159,15 @@ public class LoginActivity extends AccountAuthenticatorActivity {
.show();
}
@OnClick(R.id.forgot_password)
void forgotPassword() {
protected void forgotPassword() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
}
@OnClick(R.id.about_privacy_policy)
void onPrivacyPolicyClicked() {
protected void onPrivacyPolicyClicked() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
}
@OnClick(R.id.sign_up_button)
void signUp() {
protected void signUp() {
Intent intent = new Intent(this, SignupActivity.class);
startActivity(intent);
}
@ -232,76 +205,65 @@ public class LoginActivity extends AccountAuthenticatorActivity {
} catch (Exception e) {
e.printStackTrace();
}
usernameEdit.removeTextChangedListener(textWatcher);
passwordEdit.removeTextChangedListener(textWatcher);
twoFactorEdit.removeTextChangedListener(textWatcher);
binding.loginUsername.removeTextChangedListener(textWatcher);
binding.loginPassword.removeTextChangedListener(textWatcher);
binding.loginTwoFactor.removeTextChangedListener(textWatcher);
delegate.onDestroy();
if(null!=loginClient) {
loginClient.cancel();
}
binding = null;
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();
final String username = Objects.requireNonNull(binding.loginUsername.getText()).toString();
final String password = Objects.requireNonNull(binding.loginPassword.getText()).toString();
final String twoFactorCode = Objects.requireNonNull(binding.loginTwoFactor.getText()).toString();
showLoggingProgressBar();
doLogin(username, password, twoFactorCode);
loginClient.doLogin(username, password, twoFactorCode, Locale.getDefault().getLanguage(),
new LoginCallback() {
@Override
public void success(@NonNull LoginResult loginResult) {
runOnUiThread(()->{
Timber.d("Login Success");
hideProgress();
onLoginSuccess(loginResult);
});
}
@Override
public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) {
runOnUiThread(()->{
Timber.d("Requesting 2FA prompt");
hideProgress();
askUserForTwoFactorAuth();
});
}
@Override
public void passwordResetPrompt(@Nullable String token) {
runOnUiThread(()->{
Timber.d("Showing password reset prompt");
hideProgress();
showPasswordResetPrompt();
});
}
@Override
public void error(@NonNull Throwable caught) {
runOnUiThread(()->{
Timber.e(caught);
hideProgress();
showMessageAndCancelDialog(caught.getLocalizedMessage());
});
}
});
}
private void doLogin(String username, String password, String twoFactorCode) {
progressDialog.show();
loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
loginToken.enqueue(
new Callback<MwQueryResponse>() {
@Override
public void onResponse(Call<MwQueryResponse> call,
Response<MwQueryResponse> 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<MwQueryResponse> call, Throwable t) {
Timber.e(t);
showMessageAndCancelDialog(t.getLocalizedMessage());
}
});
}
private void hideProgress() {
progressDialog.dismiss();
@ -332,13 +294,9 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
private void onLoginSuccess(LoginResult loginResult) {
if (!progressDialog.isShowing()) {
// no longer attached to activity!
return;
}
compositeDisposable.clear();
sessionManager.setUserLoggedIn(true);
AppAdapter.get().updateAccount(loginResult);
sessionManager.updateAccount(loginResult);
progressDialog.dismiss();
showSuccessAndDismissDialog();
startMainActivity();
@ -385,9 +343,9 @@ public class LoginActivity extends AccountAuthenticatorActivity {
public void askUserForTwoFactorAuth() {
progressDialog.dismiss();
twoFactorContainer.setVisibility(VISIBLE);
twoFactorEdit.setVisibility(VISIBLE);
twoFactorEdit.requestFocus();
binding.twoFactorContainer.setVisibility(VISIBLE);
binding.loginTwoFactor.setVisibility(VISIBLE);
binding.loginTwoFactor.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);
@ -418,15 +376,15 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
errorMessage.setText(getString(resId));
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
errorMessageContainer.setVisibility(VISIBLE);
binding.errorMessage.setText(getString(resId));
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
binding.errorMessageContainer.setVisibility(VISIBLE);
}
private void showMessage(String message, @ColorRes int colorResId) {
errorMessage.setText(message);
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
errorMessageContainer.setVisibility(VISIBLE);
binding.errorMessage.setText(message);
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
binding.errorMessageContainer.setVisibility(VISIBLE);
}
private AppCompatDelegate getDelegate() {
@ -447,9 +405,11 @@ public class LoginActivity extends AccountAuthenticatorActivity {
@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);
boolean enabled = binding.loginUsername.getText().length() != 0 &&
binding.loginPassword.getText().length() != 0 &&
(BuildConfig.DEBUG || binding.loginTwoFactor.getText().length() != 0 ||
binding.loginTwoFactor.getVisibility() != VISIBLE);
binding.loginButton.setEnabled(enabled);
}
}
@ -467,22 +427,22 @@ public class LoginActivity extends AccountAuthenticatorActivity {
} else {
outState.putBoolean(saveProgressDailog,false);
}
outState.putString(saveErrorMessage,errorMessage.getText().toString()); //Save the errorMessage
outState.putString(saveErrorMessage,binding.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();
return binding.loginUsername.getText().toString();
}
private String getPassword(){
return passwordEdit.getText().toString();
return binding.loginPassword.getText().toString();
}
@Override
protected void onRestoreInstanceState(final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
usernameEdit.setText(savedInstanceState.getString(saveUsername));
passwordEdit.setText(savedInstanceState.getString(savePassword));
binding.loginUsername.setText(savedInstanceState.getString(saveUsername));
binding.loginPassword.setText(savedInstanceState.getString(savePassword));
if(savedInstanceState.getBoolean(saveProgressDailog)) {
performLogin();
}

View file

@ -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<MwPostResponse> postLogout() {
return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
}
}

View file

@ -9,8 +9,7 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.login.LoginResult;
import fr.free.nrw.commons.auth.login.LoginResult;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
@ -123,18 +122,18 @@ public class SessionManager {
}
/**
* 1. Clears existing accounts from account manager
* 2. Calls MediaWikiApi's logout function to clear cookies
* @return
* Returns a Completable that clears existing accounts from account manager
*/
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 Completable.fromObservable(
Observable.empty()
.doOnComplete(
() -> {
removeAccount();
currentAccount = null;
}
)
);
}
/**

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons.auth;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
@ -61,4 +63,20 @@ public class SignupActivity extends BaseActivity {
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
* @param overrideConfiguration
*/
@Override
public void applyOverrideConfiguration(final Configuration overrideConfiguration) {
if (Build.VERSION.SDK_INT <= 25 &&
(getResources().getConfiguration().uiMode == getApplicationContext().getResources().getConfiguration().uiMode)) {
return;
}
super.applyOverrideConfiguration(overrideConfiguration);
}
}

View file

@ -0,0 +1,170 @@
package fr.free.nrw.commons.auth.csrf
import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginCallback
import fr.free.nrw.commons.auth.login.LoginFailedException
import fr.free.nrw.commons.auth.login.LoginResult
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<MwQueryResponse?>? = 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<MwQueryResponse?> =
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<MwQueryResponse?> {
val call = service.getCsrfTokenCall()
call.enqueue(object : retrofit2.Callback<MwQueryResponse?> {
override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) {
if (call.isCanceled) {
return
}
cb.success(response.body()!!.query()!!.csrfToken())
}
override fun onFailure(call: Call<MwQueryResponse?>, 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)

View file

@ -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<MwQueryResponse?>
}

View file

@ -0,0 +1,8 @@
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()
}

View file

@ -0,0 +1,8 @@
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)
}

View file

@ -0,0 +1,193 @@
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 io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
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<MwQueryResponse?>? = null
private var loginCall: Call<LoginResponse?>? = 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<MwQueryResponse?> {
override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) {
login(
userName, password, null, null, response.body()!!.query()!!.loginToken(),
userLanguage, cb
)
}
override fun onFailure(call: Call<MwQueryResponse?>, 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<LoginResponse?> {
override fun onResponse(
call: Call<LoginResponse?>,
response: Response<LoginResponse?>
) {
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<LoginResponse?>, 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<MwQueryResponse?>{
override fun onResponse(
call: Call<MwQueryResponse?>,
response: Response<MwQueryResponse?>
) = 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<MwQueryResponse?>, 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)?.groups ?: 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
}
}
}

View file

@ -0,0 +1,3 @@
package fr.free.nrw.commons.auth.login
class LoginFailedException(message: String?) : Throwable(message)

View file

@ -0,0 +1,45 @@
package fr.free.nrw.commons.auth.login
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
import io.reactivex.Observable
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
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<MwQueryResponse?>
@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<LoginResponse?>
@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<LoginResponse?>
@GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
fun getUserInfo(@Query("ususers") userName: String): Observable<MwQueryResponse?>
}

View file

@ -0,0 +1,63 @@
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? {
return clientLogin?.toLoginResult(password)
}
}
internal class ClientLogin {
private val status: String? = null
private val requests: List<Request>? = 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<String, RequestField>? = null
fun id(): String? = id
}
internal class RequestField {
private val type: String? = null
private val label: String? = null
private val help: String? = null
}

View file

@ -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<String>()
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)
}

View file

@ -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;
}
}

View file

@ -12,8 +12,6 @@ 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.items.BookmarkItemsFragment;
@ -22,6 +20,7 @@ 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;
@ -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
@ -70,9 +68,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
@ -184,7 +181,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);
@ -241,9 +238,9 @@ 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);
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);
@ -253,4 +250,10 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
public void onBackStackChanged() {
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -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 {

View file

@ -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<DepictedItem> 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;
}
}

View file

@ -46,11 +46,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 +126,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;
}
@ -149,7 +149,7 @@ public class BookmarkLocationsDao {
@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)));
@ -207,40 +207,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 +308,4 @@ public class BookmarkLocationsDao {
}
}
}
}
}

View file

@ -1,41 +1,57 @@
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.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;
@Inject BookmarkLocationsDao bookmarkLocationDao;
@Inject CommonPlaceClickActions commonPlaceClickActions;
private PlaceAdapter adapter;
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for(final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
contributionController.locationPermissionCallback.onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher);
} else {
contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied));
}
}
}
});
/**
* Create an instance of the fragment with the right bundle parameters
@ -51,25 +67,25 @@ public class BookmarkLocationsFragment extends DaggerFragment {
ViewGroup container,
Bundle savedInstanceState
) {
View v = inflater.inflate(R.layout.fragment_bookmarks_locations, container, false);
ButterKnife.bind(this, v);
return v;
binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
progressBar.setVisibility(View.VISIBLE);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
binding.loadingImagesProgressBar.setVisibility(View.VISIBLE);
binding.listView.setLayoutManager(new LinearLayoutManager(getContext()));
adapter = new PlaceAdapter(bookmarkLocationDao,
place -> Unit.INSTANCE,
(place, isBookmarked) -> {
adapter.remove(place);
return Unit.INSTANCE;
},
commonPlaceClickActions
commonPlaceClickActions,
inAppCameraLocationPermissionLauncher
);
recyclerView.setAdapter(adapter);
binding.listView.setAdapter(adapter);
}
@Override
@ -84,12 +100,12 @@ public class BookmarkLocationsFragment extends DaggerFragment {
private void initList() {
List<Place> places = controller.loadFavoritesLocations();
adapter.setItems(places);
progressBar.setVisibility(View.GONE);
binding.loadingImagesProgressBar.setVisibility(View.GONE);
if (places.size() <= 0) {
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);
}
}
@ -97,4 +113,10 @@ public class BookmarkLocationsFragment extends DaggerFragment {
public void onActivityResult(int requestCode, int resultCode, Intent data) {
contributionController.handleActivityResult(getActivity(), requestCode, resultCode, data);
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -9,20 +9,15 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.GridView;
import android.widget.ListAdapter;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import butterknife.BindView;
import butterknife.ButterKnife;
import dagger.android.support.DaggerFragment;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment;
import fr.free.nrw.commons.category.GridViewAdapter;
import fr.free.nrw.commons.databinding.FragmentBookmarksPicturesBinding;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
@ -37,11 +32,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
private GridViewAdapter gridAdapter;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
@BindView(R.id.statusMessage) TextView statusTextView;
@BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
@BindView(R.id.bookmarkedPicturesList) GridView gridView;
@BindView(R.id.parentLayout) RelativeLayout parentLayout;
private FragmentBookmarksPicturesBinding binding;
@Inject
BookmarkPicturesController controller;
@ -59,15 +50,14 @@ public class BookmarkPicturesFragment extends DaggerFragment {
ViewGroup container,
Bundle savedInstanceState
) {
View v = inflater.inflate(R.layout.fragment_bookmarks_pictures, container, false);
ButterKnife.bind(this, v);
return v;
binding = FragmentBookmarksPicturesBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getParentFragment());
binding.bookmarkedPicturesList.setOnItemClickListener((AdapterView.OnItemClickListener) getParentFragment());
initList();
}
@ -81,13 +71,14 @@ public class BookmarkPicturesFragment extends DaggerFragment {
public void onDestroy() {
super.onDestroy();
compositeDisposable.clear();
binding = null;
}
@Override
public void onResume() {
super.onResume();
if (controller.needRefreshBookmarkedPictures()) {
gridView.setVisibility(GONE);
binding.bookmarkedPicturesList.setVisibility(GONE);
if (gridAdapter != null) {
gridAdapter.clear();
((BookmarkListRootFragment)getParentFragment()).viewPagerNotifyDataSetChanged();
@ -107,8 +98,8 @@ public class BookmarkPicturesFragment extends DaggerFragment {
return;
}
progressBar.setVisibility(VISIBLE);
statusTextView.setVisibility(GONE);
binding.loadingImagesProgressBar.setVisibility(VISIBLE);
binding.statusMessage.setVisibility(GONE);
compositeDisposable.add(controller.loadBookmarkedPictures()
.subscribeOn(Schedulers.io())
@ -120,12 +111,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
* Handles the UI updates for no internet scenario
*/
private void handleNoInternet() {
progressBar.setVisibility(GONE);
binding.loadingImagesProgressBar.setVisibility(GONE);
if (gridAdapter == null || gridAdapter.isEmpty()) {
statusTextView.setVisibility(VISIBLE);
statusTextView.setText(getString(R.string.no_internet));
binding.statusMessage.setVisibility(VISIBLE);
binding.statusMessage.setText(getString(R.string.no_internet));
} else {
ViewUtil.showShortSnackbar(parentLayout, R.string.no_internet);
ViewUtil.showShortSnackbar(binding.parentLayout, R.string.no_internet);
}
}
@ -136,7 +127,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading images inside a category");
try{
ViewUtil.showShortSnackbar(parentLayout, R.string.error_loading_images);
ViewUtil.showShortSnackbar(binding.getRoot(), R.string.error_loading_images);
initErrorView();
}catch (Exception e){
e.printStackTrace();
@ -147,12 +138,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
* Handles the UI updates for a error scenario
*/
private void initErrorView() {
progressBar.setVisibility(GONE);
binding.loadingImagesProgressBar.setVisibility(GONE);
if (gridAdapter == null || gridAdapter.isEmpty()) {
statusTextView.setVisibility(VISIBLE);
statusTextView.setText(getString(R.string.no_images_found));
binding.statusMessage.setVisibility(VISIBLE);
binding.statusMessage.setText(getString(R.string.no_images_found));
} else {
statusTextView.setVisibility(GONE);
binding.statusMessage.setVisibility(GONE);
}
}
@ -160,12 +151,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
* Handles the UI updates when there is no bookmarks
*/
private void initEmptyBookmarkListView() {
progressBar.setVisibility(GONE);
binding.loadingImagesProgressBar.setVisibility(GONE);
if (gridAdapter == null || gridAdapter.isEmpty()) {
statusTextView.setVisibility(VISIBLE);
statusTextView.setText(getString(R.string.bookmark_empty));
binding.statusMessage.setVisibility(VISIBLE);
binding.statusMessage.setText(getString(R.string.bookmark_empty));
} else {
statusTextView.setVisibility(GONE);
binding.statusMessage.setVisibility(GONE);
}
}
@ -188,18 +179,18 @@ public class BookmarkPicturesFragment extends DaggerFragment {
setAdapter(collection);
} else {
if (gridAdapter.containsAll(collection)) {
progressBar.setVisibility(GONE);
statusTextView.setVisibility(GONE);
gridView.setVisibility(VISIBLE);
gridView.setAdapter(gridAdapter);
binding.loadingImagesProgressBar.setVisibility(GONE);
binding.statusMessage.setVisibility(GONE);
binding.bookmarkedPicturesList.setVisibility(VISIBLE);
binding.bookmarkedPicturesList.setAdapter(gridAdapter);
return;
}
gridAdapter.addItems(collection);
((BookmarkListRootFragment) getParentFragment()).viewPagerNotifyDataSetChanged();
}
progressBar.setVisibility(GONE);
statusTextView.setVisibility(GONE);
gridView.setVisibility(VISIBLE);
binding.loadingImagesProgressBar.setVisibility(GONE);
binding.statusMessage.setVisibility(GONE);
binding.bookmarkedPicturesList.setVisibility(VISIBLE);
}
/**
@ -212,7 +203,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
R.layout.layout_category_images,
mediaList
);
gridView.setAdapter(gridAdapter);
binding.bookmarkedPicturesList.setAdapter(gridAdapter);
}
/**
@ -221,6 +212,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
* @return GridView Adapter
*/
public ListAdapter getAdapter() {
return gridView.getAdapter();
return binding.bookmarkedPicturesList.getAdapter();
}
}

View file

@ -3,22 +3,20 @@ package fr.free.nrw.commons.campaigns;
import android.content.Context;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.campaigns.models.Campaign;
import fr.free.nrw.commons.databinding.LayoutCampaginBinding;
import fr.free.nrw.commons.theme.BaseActivity;
import org.wikipedia.util.DateUtil;
import fr.free.nrw.commons.utils.DateUtil;
import java.text.ParseException;
import java.util.Date;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.contributions.MainActivity;
@ -31,6 +29,7 @@ import fr.free.nrw.commons.utils.ViewUtil;
*/
public class CampaignView extends SwipableCardView {
Campaign campaign;
private LayoutCampaginBinding binding;
private ViewHolder viewHolder;
public static final String CAMPAIGNS_DEFAULT_PREFERENCE = "displayCampaignsCardView";
@ -69,15 +68,15 @@ public class CampaignView extends SwipableCardView {
@Override public boolean onSwipe(final View view) {
view.setVisibility(View.GONE);
((BaseActivity) getContext()).defaultKvStore
.putBoolean(campaignPreference, false);
.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false);
ViewUtil.showLongToast(getContext(),
getResources().getString(R.string.nearby_campaign_dismiss_message));
return true;
}
private void init() {
final View rootView = inflate(getContext(), R.layout.layout_campagin, this);
viewHolder = new ViewHolder(rootView);
binding = LayoutCampaginBinding.inflate(LayoutInflater.from(getContext()), this, true);
viewHolder = new ViewHolder();
setOnClickListener(view -> {
if (campaign != null) {
if (campaign.isWLMCampaign()) {
@ -90,27 +89,16 @@ public class CampaignView extends SwipableCardView {
}
public class ViewHolder {
@BindView(R.id.iv_campaign)
ImageView ivCampaign;
@BindView(R.id.tv_title) TextView tvTitle;
@BindView(R.id.tv_description) TextView tvDescription;
@BindView(R.id.tv_dates) TextView tvDates;
public ViewHolder(View itemView) {
ButterKnife.bind(this, itemView);
}
public void init() {
if (campaign != null) {
ivCampaign.setImageDrawable(
binding.ivCampaign.setImageDrawable(
getResources().getDrawable(R.drawable.ic_campaign));
tvTitle.setText(campaign.getTitle());
tvDescription.setText(campaign.getDescription());
binding.tvTitle.setText(campaign.getTitle());
binding.tvDescription.setText(campaign.getDescription());
try {
if (campaign.isWLMCampaign()) {
tvDates.setText(
binding.tvDates.setText(
String.format("%1s - %2s", campaign.getStartDate(),
campaign.getEndDate()));
} else {
@ -118,7 +106,7 @@ public class CampaignView extends SwipableCardView {
.parse(campaign.getStartDate());
final Date endDate = CommonsDateUtil.getIso8601DateFormatShort()
.parse(campaign.getEndDate());
tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
binding.tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
DateUtil.getExtraShortDateString(endDate)));
}
} catch (final ParseException e) {

View file

@ -27,30 +27,42 @@ class CategoriesModel @Inject constructor(
private var selectedExistingCategories: MutableList<String> = mutableListOf()
/**
* Returns if the item contains an year
* @param item
* Returns true if an item is considered to be a spammy category which should be ignored
*
* @param item a category item that needs to be validated to know if it is spammy or not
* @return
*/
fun containsYear(item: String): Boolean {
fun isSpammyCategory(item: String): Boolean {
//Check for current and previous year to exclude these categories from removal
val now = Calendar.getInstance()
val year = now[Calendar.YEAR]
val yearInString = year.toString()
val prevYear = year - 1
val curYear = now[Calendar.YEAR]
val curYearInString = curYear.toString()
val prevYear = curYear - 1
val prevYearInString = prevYear.toString()
Timber.d("Previous year: %s", prevYearInString)
//Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
//And that item does not equal the current year or previous year
//And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
//Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
return item.matches(".*(19|20)\\d{2}.*".toRegex())
&& !item.contains(yearInString)
&& !item.contains(prevYearInString)
|| item.matches("(.*)needing(.*)".toRegex())
|| item.matches("(.*)taken on(.*)".toRegex())
|| item.matches(".*0s.*".toRegex())
&& !item.matches(".*(200|201)0s.*".toRegex())
val mentionsDecade = item.matches(".*0s.*".toRegex())
val recentDecade = item.matches(".*20[0-2]0s.*".toRegex())
val spammyCategory = item.matches("(.*)needing(.*)".toRegex())
|| item.matches("(.*)taken on(.*)".toRegex())
// always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
if (spammyCategory) {
return true
}
if (mentionsDecade) {
// Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029
// Example: "2020s" is OK, but "1920s" is not (and should be skipped)
return !recentDecade
} else {
// If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year
// anywhere within the string (.* is wildcard) (Issue #47)
// And that item does not equal the current year or previous year
return item.matches(".*(19|20)\\d{2}.*".toRegex())
&& !item.contains(curYearInString)
&& !item.contains(prevYearInString)
}
}
/**
@ -136,7 +148,11 @@ class CategoriesModel @Inject constructor(
return Observable.fromIterable(categoryNames)
.map { categoryName ->
buildCategories(categoryName)
}.toList().toObservable()
}
.filter { categoryItem ->
categoryItem.name != "Hidden"
}
.toList().toObservable()
}
/**

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.category
import io.reactivex.Single
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -15,13 +15,12 @@ import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding;
import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment;
import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment;
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment;
@ -29,7 +28,7 @@ import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import java.util.ArrayList;
import java.util.List;
import org.wikipedia.page.PageTitle;
import fr.free.nrw.commons.wikidata.model.page.PageTitle;
/**
* This activity displays details of a particular category
@ -45,23 +44,23 @@ public class CategoryDetailsActivity extends BaseActivity
private CategoriesMediaFragment categoriesMediaFragment;
private MediaDetailPagerFragment mediaDetails;
private String categoryName;
@BindView(R.id.mediaContainer) FrameLayout mediaContainer;
@BindView(R.id.tab_layout) TabLayout tabLayout;
@BindView(R.id.viewPager) ViewPager viewPager;
@BindView(R.id.toolbar) Toolbar toolbar;
ViewPagerAdapter viewPagerAdapter;
private ActivityCategoryDetailsBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_category_details);
ButterKnife.bind(this);
binding = ActivityCategoryDetailsBinding.inflate(getLayoutInflater());
final View view = binding.getRoot();
setContentView(view);
supportFragmentManager = getSupportFragmentManager();
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
viewPager.setAdapter(viewPagerAdapter);
viewPager.setOffscreenPageLimit(2);
tabLayout.setupWithViewPager(viewPager);
setSupportActionBar(toolbar);
binding.viewPager.setAdapter(viewPagerAdapter);
binding.viewPager.setOffscreenPageLimit(2);
binding.tabLayout.setupWithViewPager(binding.viewPager);
setSupportActionBar(binding.toolbarBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setTabs();
setPageTitle();
@ -110,12 +109,12 @@ public class CategoryDetailsActivity extends BaseActivity
*/
@Override
public void onMediaClicked(int position) {
tabLayout.setVisibility(View.GONE);
viewPager.setVisibility(View.GONE);
mediaContainer.setVisibility(View.VISIBLE);
binding.tabLayout.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.GONE);
binding.mediaContainer.setVisibility(View.VISIBLE);
if (mediaDetails == null || !mediaDetails.isVisible()) {
// set isFeaturedImage true for featured images, to include author field on media detail
mediaDetails = new MediaDetailPagerFragment(false, true);
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
FragmentManager supportFragmentManager = getSupportFragmentManager();
supportFragmentManager
.beginTransaction()
@ -216,9 +215,9 @@ public class CategoryDetailsActivity extends BaseActivity
@Override
public void onBackPressed() {
if (supportFragmentManager.getBackStackEntryCount() == 1){
tabLayout.setVisibility(View.VISIBLE);
viewPager.setVisibility(View.VISIBLE);
mediaContainer.setVisibility(View.GONE);
binding.tabLayout.setVisibility(View.VISIBLE);
binding.viewPager.setVisibility(View.VISIBLE);
binding.mediaContainer.setVisibility(View.GONE);
}
super.onBackPressed();
}

View file

@ -5,7 +5,6 @@ import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_E
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;

View file

@ -1,71 +0,0 @@
package fr.free.nrw.commons.category;
import io.reactivex.Single;
import java.util.Map;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import retrofit2.http.GET;
import retrofit2.http.Query;
import retrofit2.http.QueryMap;
/**
* Interface for interacting with Commons category related APIs
*/
public interface CategoryInterface {
/**
* Searches for categories with the specified name.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70"
+ "&gsrnamespace=14")
Single<MwQueryResponse> searchCategories(@Query("gsrsearch") String filter,
@Query("gsrlimit") int itemLimit,
@Query("gsroffset") int offset);
/**
* Searches for categories starting with the specified prefix.
*
* @param prefix The string to be searched
* @param itemLimit How many results are returned
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail"
+ "&pithumbsize=70")
Single<MwQueryResponse> searchCategoriesForPrefix(@Query("gacprefix") String prefix,
@Query("gaclimit") int itemLimit,
@Query("gacoffset") int offset);
/**
* Fetches categories starting and ending with a specified name.
*
* @param startingCategory Name of the category to start
* @param endingCategory Name of the category to end
* @param itemLimit How many categories to return
* @param offset offset
* @return MwQueryResponse
*/
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail"
+ "&pithumbsize=70")
Single<MwQueryResponse> getCategoriesByName(@Query("gacfrom") String startingCategory,
@Query("gacto") String endingCategory,
@Query("gaclimit") int itemLimit,
@Query("gacoffset") int offset);
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=categorymembers&gcmtype=subcat"
+ "&prop=info&gcmlimit=50")
Single<MwQueryResponse> getSubCategoryList(@Query("gcmtitle") String categoryName,
@QueryMap(encoded = true) Map<String, String> continuation);
@GET("w/api.php?action=query&format=json&formatversion=2"
+ "&generator=categories&prop=info&gcllimit=50")
Single<MwQueryResponse> getParentCategoryList(@Query("titles") String categoryName,
@QueryMap(encoded = true) Map<String, String> continuation);
}

View file

@ -0,0 +1,69 @@
package fr.free.nrw.commons.category
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.QueryMap
/**
* Interface for interacting with Commons category related APIs
*/
interface CategoryInterface {
/**
* Searches for categories with the specified name.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14")
fun searchCategories(
@Query("gsrsearch") filter: String?,
@Query("gsrlimit") itemLimit: Int,
@Query("gsroffset") offset: Int
): Single<MwQueryResponse>
/**
* Searches for categories starting with the specified prefix.
*
* @param prefix The string to be searched
* @param itemLimit How many results are returned
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70")
fun searchCategoriesForPrefix(
@Query("gacprefix") prefix: String?,
@Query("gaclimit") itemLimit: Int,
@Query("gacoffset") offset: Int
): Single<MwQueryResponse>
/**
* Fetches categories starting and ending with a specified name.
*
* @param startingCategory Name of the category to start
* @param endingCategory Name of the category to end
* @param itemLimit How many categories to return
* @param offset offset
* @return MwQueryResponse
*/
@GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70")
fun getCategoriesByName(
@Query("gacfrom") startingCategory: String?,
@Query("gacto") endingCategory: String?,
@Query("gaclimit") itemLimit: Int,
@Query("gacoffset") offset: Int
): Single<MwQueryResponse>
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
fun getSubCategoryList(
@Query("gcmtitle") categoryName: String,
@QueryMap(encoded = true) continuation: Map<String, String>
): Single<MwQueryResponse>
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50")
fun getParentCategoryList(
@Query("titles") categoryName: String?,
@QueryMap(encoded = true) continuation: Map<String, String>
): Single<MwQueryResponse>
}

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.category
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import kotlinx.parcelize.Parcelize
@Parcelize
data class CategoryItem(val name: String, val description: String?,

View file

@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.upload.UploadItem
@ -12,8 +13,9 @@ import fr.free.nrw.commons.upload.UploadMediaDetail
import fr.free.nrw.commons.upload.WikidataPlace
import fr.free.nrw.commons.upload.WikidataPlace.Companion.from
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import kotlinx.android.parcel.Parcelize
import java.util.*
import kotlinx.parcelize.Parcelize
import java.io.File
import java.util.Date
@Entity(tableName = "contribution")
@Parcelize
@ -43,7 +45,11 @@ data class Contribution constructor(
var hasInvalidLocation : Int = 0,
var contentUri: Uri? = null,
var countryCode : String? = null,
var imageSHA1 : String? = null
var imageSHA1 : String? = null,
/**
* Number of times a contribution has been retried after a failure
*/
var retries: Int = 0
) : Parcelable {
fun completeWith(media: Media): Contribution {
@ -111,6 +117,21 @@ data class Contribution constructor(
*/
fun formatDescriptions(descriptions: List<UploadMediaDetail>) =
descriptions.filter { it.descriptionText.isNotEmpty() }
.joinToString { "{{${it.languageCode}|1=${it.descriptionText}}}" }
.joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
}
val fileKey : String? get() = chunkInfo?.uploadResult?.filekey
val localUriPath: File? get() = localUri?.path?.let { File(it) }
fun isCompleted(): Boolean {
return chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
}
fun isPaused(): Boolean {
return CommonsApplication.pauseUploads[pageId] ?: false
}
fun unpause() {
CommonsApplication.pauseUploads[pageId] = false
}
}

View file

@ -2,13 +2,12 @@ package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import android.Manifest;
import android.Manifest.permission;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.filepicker.DefaultCallback;
@ -16,8 +15,13 @@ import fr.free.nrw.commons.filepicker.FilePicker;
import fr.free.nrw.commons.filepicker.FilePicker.ImageSource;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.ArrayList;
@ -31,6 +35,13 @@ public class ContributionController {
public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads";
private final JsonKvStore defaultKvStore;
private LatLng locationBeforeImageCapture;
private boolean isInAppCameraUpload;
public LocationPermissionCallback locationPermissionCallback;
private LocationPermissionsHelper locationPermissionsHelper;
@Inject
LocationServiceManager locationManager;
@Inject
public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) {
@ -40,7 +51,8 @@ public class ContributionController {
/**
* Check for permissions and initiate camera click
*/
public void initiateCameraPick(Activity activity) {
public void initiateCameraPick(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) {
boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true);
if (!useExtStorage) {
initiateCameraUpload(activity);
@ -48,10 +60,133 @@ public class ContributionController {
}
PermissionUtils.checkPermissionsAndPerformAction(activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
() -> initiateCameraUpload(activity),
R.string.storage_permission_title,
R.string.write_storage_permission_rationale);
() -> {
if (defaultKvStore.getBoolean("inAppCameraFirstRun")) {
defaultKvStore.putBoolean("inAppCameraFirstRun", false);
askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher);
} else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) {
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher);
} else {
initiateCameraUpload(activity);
}
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE);
}
/**
* Asks users to provide location access
*
* @param activity
*/
private void createDialogsAndHandleLocationPermissions(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) {
locationPermissionCallback = new LocationPermissionCallback() {
@Override
public void onLocationPermissionDenied(String toastMessage) {
Toast.makeText(
activity,
toastMessage,
Toast.LENGTH_LONG
).show();
initiateCameraUpload(activity);
}
@Override
public void onLocationPermissionGranted() {
if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
showLocationOffDialog(activity, R.string.in_app_camera_needs_location,
R.string.in_app_camera_location_unavailable);
} else {
initiateCameraUpload(activity);
}
}
};
locationPermissionsHelper = new LocationPermissionsHelper(
activity, locationManager, locationPermissionCallback);
if (inAppCameraLocationPermissionLauncher != null) {
inAppCameraLocationPermissionLauncher.launch(
new String[]{permission.ACCESS_FINE_LOCATION});
}
}
/**
* Shows a dialog alerting the user about location services being off
* and asking them to turn it on
* TODO: Add a seperate callback in LocationPermissionsHelper for this.
* Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
*
* @param activity Activity reference
* @param dialogTextResource Resource id of text to be shown in dialog
* @param toastTextResource Resource id of text to be shown in toast
*/
private void showLocationOffDialog(Activity activity, int dialogTextResource,
int toastTextResource) {
DialogUtil
.showAlertDialog(activity,
activity.getString(R.string.ask_to_turn_location_on),
activity.getString(dialogTextResource),
activity.getString(R.string.title_app_shortcut_setting),
activity.getString(R.string.cancel),
() -> locationPermissionsHelper.openLocationSettings(activity),
() -> {
Toast.makeText(activity, activity.getString(toastTextResource),
Toast.LENGTH_LONG).show();
initiateCameraUpload(activity);
}
);
}
public void handleShowRationaleFlowCameraLocation(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) {
DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title),
activity.getString(R.string.in_app_camera_location_permission_rationale),
activity.getString(android.R.string.ok),
activity.getString(android.R.string.cancel),
() -> {
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher);
},
() -> locationPermissionCallback.onLocationPermissionDenied(
activity.getString(R.string.in_app_camera_location_permission_denied)),
null,
false);
}
/**
* Suggest user to attach location information with pictures. If the user selects "Yes", then:
* <p>
* Location is taken from the EXIF if the default camera application does not redact location
* tags.
* <p>
* Otherwise, if the EXIF metadata does not have location information, then location captured by
* the app is used
*
* @param activity
*/
private void askUserToAllowLocationAccess(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) {
DialogUtil.showAlertDialog(activity,
activity.getString(R.string.in_app_camera_location_permission_title),
activity.getString(R.string.in_app_camera_location_access_explanation),
activity.getString(R.string.option_allow),
activity.getString(R.string.option_dismiss),
() -> {
defaultKvStore.putBoolean("inAppCameraLocationPref", true);
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher);
},
() -> {
ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied);
defaultKvStore.putBoolean("inAppCameraLocationPref", false);
initiateCameraUpload(activity);
},
null,
true);
}
/**
@ -65,44 +200,36 @@ public class ContributionController {
* Initiate gallery picker with permission
*/
public void initiateCustomGalleryPickWithPermission(final Activity activity) {
setPickerConfiguration(activity,true);
setPickerConfiguration(activity, true);
PermissionUtils.checkPermissionsAndPerformAction(activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
() -> {
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
PermissionUtils.checkPermissionsAndPerformAction(
activity,
permission.ACCESS_MEDIA_LOCATION,
() -> {},
R.string.media_location_permission_denied,
R.string.add_location_manually
);
}
FilePicker.openCustomSelector(activity, 0);
},
() -> FilePicker.openCustomSelector(activity, 0),
R.string.storage_permission_title,
R.string.write_storage_permission_rationale);
R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE);
}
/**
* Open chooser for gallery uploads
*/
private void initiateGalleryUpload(final Activity activity, final boolean allowMultipleUploads) {
private void initiateGalleryUpload(final Activity activity,
final boolean allowMultipleUploads) {
setPickerConfiguration(activity, allowMultipleUploads);
FilePicker.openGallery(activity, 0);
boolean openDocumentIntentPreferred = defaultKvStore.getBoolean(
"openDocumentPhotoPickerPref", true);
FilePicker.openGallery(activity, 0, openDocumentIntentPreferred);
}
/**
* Sets configuration for file picker
*/
private void setPickerConfiguration(Activity activity,
boolean allowMultipleUploads) {
boolean allowMultipleUploads) {
boolean copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true);
FilePicker.configuration(activity)
.setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage)
.setAllowMultiplePickInGallery(allowMultipleUploads);
.setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage)
.setAllowMultiplePickInGallery(allowMultipleUploads);
}
/**
@ -110,42 +237,50 @@ public class ContributionController {
*/
private void initiateCameraUpload(Activity activity) {
setPickerConfiguration(activity, false);
if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) {
locationBeforeImageCapture = locationManager.getLastLocation();
}
isInAppCameraUpload = true;
FilePicker.openCameraForImage(activity, 0);
}
/**
* Attaches callback for file picker.
*/
public void handleActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
FilePicker.handleActivityResult(requestCode, resultCode, data, activity, new DefaultCallback() {
public void handleActivityResult(Activity activity, int requestCode, int resultCode,
Intent data) {
FilePicker.handleActivityResult(requestCode, resultCode, data, activity,
new DefaultCallback() {
@Override
public void onCanceled(final ImageSource source, final int type) {
super.onCanceled(source, type);
defaultKvStore.remove(PLACE_OBJECT);
}
@Override
public void onCanceled(final ImageSource source, final int type) {
super.onCanceled(source, type);
defaultKvStore.remove(PLACE_OBJECT);
}
@Override
public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) {
ViewUtil.showShortToast(activity, R.string.error_occurred_in_picking_images);
}
@Override
public void onImagePickerError(Exception e, FilePicker.ImageSource source,
int type) {
ViewUtil.showShortToast(activity, R.string.error_occurred_in_picking_images);
}
@Override
public void onImagesPicked(@NonNull List<UploadableFile> imagesFiles, FilePicker.ImageSource source, int type) {
Intent intent = handleImagesPicked(activity, imagesFiles);
activity.startActivity(intent);
}
});
@Override
public void onImagesPicked(@NonNull List<UploadableFile> imagesFiles,
FilePicker.ImageSource source, int type) {
Intent intent = handleImagesPicked(activity, imagesFiles);
activity.startActivity(intent);
}
});
}
public List<UploadableFile> handleExternalImagesPicked(Activity activity,
Intent data) {
Intent data) {
return FilePicker.handleExternalImagesPicked(data, activity);
}
/**
* Returns intent to be passed to upload activity
* Attaches place object for nearby uploads
* Returns intent to be passed to upload activity Attaches place object for nearby uploads and
* location before image capture if in-app camera is used
*/
private Intent handleImagesPicked(Context context,
List<UploadableFile> imagesFiles) {
@ -159,7 +294,17 @@ public class ContributionController {
shareIntent.putExtra(PLACE_OBJECT, place);
}
if (locationBeforeImageCapture != null) {
shareIntent.putExtra(
UploadActivity.LOCATION_BEFORE_IMAGE_CAPTURE,
locationBeforeImageCapture);
}
shareIntent.putExtra(
UploadActivity.IN_APP_CAMERA_UPLOAD,
isInAppCameraUpload
);
isInAppCameraUpload = false; // reset the flag for next use
return shareIntent;
}
}

View file

@ -12,7 +12,6 @@ import androidx.room.Update;
import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
@Dao

View file

@ -12,14 +12,12 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AlertDialog.Builder;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import com.facebook.drawee.view.SimpleDraweeView;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback;
import fr.free.nrw.commons.databinding.LayoutContributionBinding;
import fr.free.nrw.commons.media.MediaClient;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
@ -29,29 +27,8 @@ import java.io.File;
public class ContributionViewHolder extends RecyclerView.ViewHolder {
private final Callback callback;
@BindView(R.id.contributionImage)
SimpleDraweeView imageView;
@BindView(R.id.contributionTitle)
TextView titleView;
@BindView(R.id.authorView)
TextView authorView;
@BindView(R.id.contributionState)
TextView stateView;
@BindView(R.id.contributionSequenceNumber)
TextView seqNumView;
@BindView(R.id.contributionProgress)
ProgressBar progressView;
@BindView(R.id.image_options)
RelativeLayout imageOptions;
@BindView(R.id.wikipediaButton)
ImageButton addToWikipediaButton;
@BindView(R.id.retryButton)
ImageButton retryButton;
@BindView(R.id.cancelButton)
ImageButton cancelButton;
@BindView(R.id.pauseResumeButton)
ImageButton pauseResumeButton;
LayoutContributionBinding binding;
private int position;
private Contribution contribution;
@ -67,9 +44,16 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
super(parent);
this.parent = parent;
this.mediaClient = mediaClient;
ButterKnife.bind(this, parent);
this.callback = callback;
binding = LayoutContributionBinding.bind(parent);
binding.retryButton.setOnClickListener(v -> retryUpload());
binding.cancelButton.setOnClickListener(v -> deleteUpload());
binding.contributionImage.setOnClickListener(v -> imageClicked());
binding.wikipediaButton.setOnClickListener(v -> wikipediaButtonClicked());
binding.pauseResumeButton.setOnClickListener(v -> onPauseResumeButtonClicked());
/* Set a dialog indicating that the upload is being paused. This is needed because pausing
an upload might take a dozen seconds. */
AlertDialog.Builder builder = new Builder(parent.getContext());
@ -87,14 +71,17 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
this.contribution = contribution;
this.position = position;
titleView.setText(contribution.getMedia().getMostRelevantCaption());
authorView.setText(contribution.getMedia().getAuthor());
binding.contributionTitle.setText(contribution.getMedia().getMostRelevantCaption());
binding.authorView.setText(contribution.getMedia().getAuthor());
//Removes flicker of loading image.
imageView.getHierarchy().setFadeDuration(0);
binding.contributionImage.getHierarchy().setFadeDuration(0);
imageView.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder);
imageView.getHierarchy().setFailureImage(R.drawable.image_placeholder);
binding.contributionImage.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder);
binding.contributionImage.getHierarchy().setFailureImage(R.drawable.image_placeholder);
final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(),
contribution.getLocalUri());
@ -103,73 +90,77 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource))
.setProgressiveRenderingEnabled(true)
.build();
} else if(imageSource != null) {
}
else if (URLUtil.isFileUrl(imageSource)){
imageRequest=ImageRequest.fromUri(Uri.parse(imageSource));
}
else if(imageSource != null) {
final File file = new File(imageSource);
imageRequest = ImageRequest.fromFile(file);
}
if(imageRequest != null){
imageView.setImageRequest(imageRequest);
binding.contributionImage.setImageRequest(imageRequest);
}
}
seqNumView.setText(String.valueOf(position + 1));
seqNumView.setVisibility(View.VISIBLE);
binding.contributionSequenceNumber.setText(String.valueOf(position + 1));
binding.contributionSequenceNumber.setVisibility(View.VISIBLE);
addToWikipediaButton.setVisibility(View.GONE);
binding.wikipediaButton.setVisibility(View.GONE);
switch (contribution.getState()) {
case Contribution.STATE_COMPLETED:
stateView.setVisibility(View.GONE);
progressView.setVisibility(View.GONE);
imageOptions.setVisibility(View.GONE);
stateView.setText("");
binding.contributionState.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.GONE);
binding.contributionState.setText("");
checkIfMediaExistsOnWikipediaPage(contribution);
break;
case Contribution.STATE_QUEUED:
case Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE:
progressView.setVisibility(View.GONE);
stateView.setVisibility(View.VISIBLE);
stateView.setText(R.string.contribution_state_queued);
imageOptions.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.GONE);
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.contribution_state_queued);
binding.imageOptions.setVisibility(View.GONE);
break;
case Contribution.STATE_IN_PROGRESS:
stateView.setVisibility(View.GONE);
progressView.setVisibility(View.VISIBLE);
addToWikipediaButton.setVisibility(View.GONE);
pauseResumeButton.setVisibility(View.VISIBLE);
cancelButton.setVisibility(View.GONE);
retryButton.setVisibility(View.GONE);
imageOptions.setVisibility(View.VISIBLE);
binding.contributionState.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.VISIBLE);
binding.wikipediaButton.setVisibility(View.GONE);
binding.pauseResumeButton.setVisibility(View.VISIBLE);
binding.cancelButton.setVisibility(View.GONE);
binding.retryButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
final long total = contribution.getDataLength();
final long transferred = contribution.getTransferred();
if (transferred == 0 || transferred >= total) {
progressView.setIndeterminate(true);
binding.contributionProgress.setIndeterminate(true);
} else {
progressView.setIndeterminate(false);
progressView.setProgress((int) (((double) transferred / (double) total) * 100));
binding.contributionProgress.setIndeterminate(false);
binding.contributionProgress.setProgress((int) (((double) transferred / (double) total) * 100));
}
break;
case Contribution.STATE_PAUSED:
progressView.setVisibility(View.GONE);
stateView.setVisibility(View.VISIBLE);
stateView.setText(R.string.paused);
cancelButton.setVisibility(View.VISIBLE);
retryButton.setVisibility(View.GONE);
pauseResumeButton.setVisibility(View.VISIBLE);
imageOptions.setVisibility(View.VISIBLE);
binding.contributionProgress.setVisibility(View.GONE);
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.paused);
binding.cancelButton.setVisibility(View.VISIBLE);
binding.retryButton.setVisibility(View.GONE);
binding.pauseResumeButton.setVisibility(View.VISIBLE);
binding.imageOptions.setVisibility(View.VISIBLE);
setResume();
if(pausingPopUp.isShowing()){
pausingPopUp.hide();
}
break;
case Contribution.STATE_FAILED:
stateView.setVisibility(View.VISIBLE);
stateView.setText(R.string.contribution_state_failed);
progressView.setVisibility(View.GONE);
cancelButton.setVisibility(View.VISIBLE);
retryButton.setVisibility(View.VISIBLE);
pauseResumeButton.setVisibility(View.GONE);
imageOptions.setVisibility(View.VISIBLE);
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.contribution_state_failed);
binding.contributionProgress.setVisibility(View.GONE);
binding.cancelButton.setVisibility(View.VISIBLE);
binding.retryButton.setVisibility(View.VISIBLE);
binding.pauseResumeButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
break;
}
}
@ -203,11 +194,11 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
*/
private void displayWikipediaButton(Boolean mediaExists) {
if (!mediaExists) {
addToWikipediaButton.setVisibility(View.VISIBLE);
binding.wikipediaButton.setVisibility(View.VISIBLE);
isWikipediaButtonDisplayed = true;
cancelButton.setVisibility(View.GONE);
retryButton.setVisibility(View.GONE);
imageOptions.setVisibility(View.VISIBLE);
binding.cancelButton.setVisibility(View.GONE);
binding.retryButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
}
}
@ -229,7 +220,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
/**
* Retry upload when it is failed
*/
@OnClick(R.id.retryButton)
public void retryUpload() {
callback.retryUpload(contribution);
}
@ -237,17 +227,14 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
/**
* Delete a failed upload attempt
*/
@OnClick(R.id.cancelButton)
public void deleteUpload() {
callback.deleteUpload(contribution);
}
@OnClick(R.id.contributionImage)
public void imageClicked() {
callback.openMediaDetail(position, isWikipediaButtonDisplayed);
}
@OnClick(R.id.wikipediaButton)
public void wikipediaButtonClicked() {
callback.addImageToWikipedia(contribution);
}
@ -255,9 +242,8 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
/**
* Triggers a callback for pause/resume
*/
@OnClick(R.id.pauseResumeButton)
public void onPauseResumeButtonClicked() {
if (pauseResumeButton.getTag().toString().equals("pause")) {
if (binding.pauseResumeButton.getTag().toString().equals("pause")) {
pause();
} else {
resume();
@ -279,16 +265,16 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
* Update pause/resume button to show pause state
*/
private void setPaused() {
pauseResumeButton.setImageResource(R.drawable.pause_icon);
pauseResumeButton.setTag(parent.getContext().getString(R.string.pause));
binding.pauseResumeButton.setImageResource(R.drawable.pause_icon);
binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.pause));
}
/**
* Update pause/resume button to show resume state
*/
private void setResume() {
pauseResumeButton.setImageResource(R.drawable.play_icon);
pauseResumeButton.setTag(parent.getContext().getString(R.string.resume));
binding.pauseResumeButton.setImageResource(R.drawable.play_icon);
binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.resume));
}
public ImageRequest getImageRequest() {

View file

@ -1,14 +1,21 @@
package fr.free.nrw.commons.contributions;
import static android.content.Context.SENSOR_SERVICE;
import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED;
import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED;
import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL;
import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME;
import static fr.free.nrw.commons.utils.LengthUtils.computeBearing;
import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween;
import android.Manifest;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
@ -21,6 +28,9 @@ import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
@ -29,17 +39,17 @@ import androidx.fragment.app.FragmentTransaction;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.databinding.FragmentContributionsBinding;
import fr.free.nrw.commons.notification.models.Notification;
import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.theme.BaseActivity;
import java.util.Date;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import androidx.work.WorkManager;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.campaigns.models.Campaign;
@ -73,18 +83,27 @@ import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
public class ContributionsFragment
extends CommonsDaggerSupportFragment
implements
OnBackStackChangedListener,
LocationUpdateListener,
extends CommonsDaggerSupportFragment
implements
OnBackStackChangedListener,
LocationUpdateListener,
MediaDetailProvider,
ICampaignsView, ContributionsContract.View, Callback{
@Inject @Named("default_preferences") JsonKvStore store;
@Inject NearbyController nearbyController;
@Inject OkHttpJsonApiClient okHttpJsonApiClient;
@Inject CampaignsPresenter presenter;
@Inject LocationServiceManager locationManager;
@Inject NotificationController notificationController;
SensorEventListener,
ICampaignsView, ContributionsContract.View, Callback {
@Inject
@Named("default_preferences")
JsonKvStore store;
@Inject
NearbyController nearbyController;
@Inject
OkHttpJsonApiClient okHttpJsonApiClient;
@Inject
CampaignsPresenter presenter;
@Inject
LocationServiceManager locationManager;
@Inject
NotificationController notificationController;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
@ -92,20 +111,18 @@ public class ContributionsFragment
private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag";
private MediaDetailPagerFragment mediaDetailPagerFragment;
static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag";
private static final int MAX_RETRIES = 10;
@BindView(R.id.card_view_nearby) public NearbyNotificationCardView nearbyNotificationCardView;
@BindView(R.id.campaigns_view) CampaignView campaignView;
@BindView(R.id.limited_connection_enabled_layout) LinearLayout limitedConnectionEnabledLayout;
@BindView(R.id.limited_connection_description_text_view) TextView limitedConnectionDescriptionTextView;
public FragmentContributionsBinding binding;
@Inject ContributionsPresenter contributionsPresenter;
@Inject
SessionManager sessionManager;
private LatLng curLatLng;
private LatLng currentLatLng;
private boolean firstLocationUpdate = true;
private boolean isFragmentAttachedBefore = false;
private View checkBoxView;
private CheckBox checkBox;
@ -117,6 +134,34 @@ public class ContributionsFragment
String userName;
private boolean isUserProfile;
private SensorManager mSensorManager;
private Sensor mLight;
private float direction;
private ActivityResultLauncher<String[]> nearbyLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
} else {
displayYouWontSeeNearbyMessage();
}
}
}
});
@NonNull
public static ContributionsFragment newInstance() {
ContributionsFragment fragment = new ContributionsFragment();
@ -133,17 +178,21 @@ public class ContributionsFragment
userName = getArguments().getString(KEY_USERNAME);
isUserProfile = true;
}
mSensorManager = (SensorManager) getActivity().getSystemService(SENSOR_SERVICE);
mLight = mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_contributions, container, false);
ButterKnife.bind(this, view);
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentContributionsBinding.inflate(inflater, container, false);
initWLMCampaign();
presenter.onAttachView(this);
contributionsPresenter.onAttachView(this);
campaignView.setVisibility(View.GONE);
binding.campaignsView.setVisibility(View.GONE);
checkBoxView = View.inflate(getActivity(), R.layout.nearby_permission_dialog, null);
checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again);
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
@ -153,6 +202,7 @@ public class ContributionsFragment
}
});
if (savedInstanceState != null) {
mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager()
.findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG);
@ -163,13 +213,13 @@ public class ContributionsFragment
initFragments();
if(isUserProfile) {
limitedConnectionEnabledLayout.setVisibility(View.GONE);
binding.limitedConnectionEnabledLayout.setVisibility(View.GONE);
}else {
upDateUploadCount();
}
if(shouldShowMediaDetailsFragment){
if (shouldShowMediaDetailsFragment) {
showMediaDetailPagerFragment();
}else{
} else {
if (mediaDetailPagerFragment != null) {
removeFragment(mediaDetailPagerFragment);
}
@ -180,9 +230,9 @@ public class ContributionsFragment
&& sessionManager.getCurrentAccount() != null && !isUserProfile) {
setUploadCount();
}
limitedConnectionEnabledLayout.setOnClickListener(toggleDescriptionListener);
binding.limitedConnectionEnabledLayout.setOnClickListener(toggleDescriptionListener);
setHasOptionsMenu(true);
return view;
return binding.getRoot();
}
/**
@ -195,10 +245,13 @@ public class ContributionsFragment
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) {
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
// Removing contributions menu items for ProfileActivity
if (getActivity() instanceof ProfileActivity) { return; }
if (getActivity() instanceof ProfileActivity) {
return;
}
inflater.inflate(R.menu.contribution_activity_notification_menu, menu);
@ -220,7 +273,7 @@ public class ContributionsFragment
throwable -> Timber.e(throwable, "Error occurred while loading notifications")));
}
public void scrollToTop( ){
public void scrollToTop() {
if (contributionsListFragment != null) {
contributionsListFragment.scrollToTop();
}
@ -242,22 +295,17 @@ public class ContributionsFragment
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false);
checkable.setChecked(isEnabled);
if (isEnabled) {
limitedConnectionEnabledLayout.setVisibility(View.VISIBLE);
} else {
limitedConnectionEnabledLayout.setVisibility(View.GONE);
if (binding!=null) {
binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE);
}
checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24);
checkable.setOnMenuItemClickListener(new OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
((MainActivity) getActivity()).toggleLimitedConnectionMode();
boolean isEnabled = store.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false);
if (isEnabled) {
limitedConnectionEnabledLayout.setVisibility(View.VISIBLE);
} else {
limitedConnectionEnabledLayout.setVisibility(View.GONE);
}
binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE);
checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24);
return false;
}
@ -285,28 +333,31 @@ public class ContributionsFragment
*/
private void showContributionsListFragment() {
// show nearby card view on contributions list is visible
if (nearbyNotificationCardView != null && !isUserProfile) {
if (binding.cardViewNearby != null && !isUserProfile) {
if (store.getBoolean("displayNearbyCardView", true)) {
if (nearbyNotificationCardView.cardViewVisibilityState
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
nearbyNotificationCardView.setVisibility(View.VISIBLE);
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
nearbyNotificationCardView.setVisibility(View.GONE);
binding.cardViewNearby.setVisibility(View.GONE);
}
}
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, mediaDetailPagerFragment);
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
}
private void showMediaDetailPagerFragment() {
// hide nearby card view on media detail is visible
setupViewForMediaDetails();
showFragment(mediaDetailPagerFragment, MEDIA_DETAIL_PAGER_FRAGMENT_TAG, contributionsListFragment);
showFragment(mediaDetailPagerFragment, MEDIA_DETAIL_PAGER_FRAGMENT_TAG,
contributionsListFragment);
}
private void setupViewForMediaDetails() {
campaignView.setVisibility(View.GONE);
nearbyNotificationCardView.setVisibility(View.GONE);
if (binding!=null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
@Override
@ -328,7 +379,8 @@ public class ContributionsFragment
showContributionsListFragment();
}
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, mediaDetailPagerFragment);
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
}
/**
@ -351,7 +403,7 @@ public class ContributionsFragment
transaction.addToBackStack(tag);
transaction.commit();
getChildFragmentManager().executePendingTransactions();
}else if (!fragment.isAdded() && otherFragment != null ) {
} else if (!fragment.isAdded() && otherFragment != null) {
transaction.hide(otherFragment);
transaction.add(R.id.root_frame, fragment, tag);
transaction.addToBackStack(tag);
@ -376,21 +428,21 @@ public class ContributionsFragment
@SuppressWarnings("ConstantConditions")
private void setUploadCount() {
compositeDisposable.add(okHttpJsonApiClient
.getUploadCount(((MainActivity)getActivity()).sessionManager.getCurrentAccount().name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayUploadCount,
t -> Timber.e(t, "Fetching upload count failed")
));
.getUploadCount(((MainActivity) getActivity()).sessionManager.getCurrentAccount().name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayUploadCount,
t -> Timber.e(t, "Fetching upload count failed")
));
}
private void displayUploadCount(Integer uploadCount) {
if (getActivity().isFinishing()
|| getResources() == null) {
|| getResources() == null) {
return;
}
((MainActivity)getActivity()).setNumOfUploads(uploadCount);
((MainActivity) getActivity()).setNumOfUploads(uploadCount);
}
@ -399,6 +451,7 @@ public class ContributionsFragment
super.onPause();
locationManager.removeLocationListener(this);
locationManager.unregisterLocationManager();
mSensorManager.unregisterListener(this);
}
@Override
@ -410,9 +463,13 @@ public class ContributionsFragment
public void onResume() {
super.onResume();
contributionsPresenter.onAttachView(this);
firstLocationUpdate = true;
locationManager.addLocationListener(this);
nearbyNotificationCardView.permissionRequestButton.setOnClickListener(v -> {
if (binding==null) {
return;
}
binding.cardViewNearby.permissionRequestButton.setOnClickListener(v -> {
showNearbyCardPermissionRationale();
});
@ -420,13 +477,20 @@ public class ContributionsFragment
if (mediaDetailPagerFragment == null && !isUserProfile) {
if (store.getBoolean("displayNearbyCardView", true)) {
checkPermissionsAndShowNearbyCardView();
if (nearbyNotificationCardView.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
nearbyNotificationCardView.setVisibility(View.VISIBLE);
// Calling nearby card to keep showing it even when user clicks on it and comes back
try {
updateClosestNearbyCardViewInfo();
} catch (Exception e) {
Timber.e(e);
}
if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
// Hide nearby notification card view if related shared preferences is false
nearbyNotificationCardView.setVisibility(View.GONE);
binding.cardViewNearby.setVisibility(View.GONE);
}
// Notification Count and Campaigns should not be set, if it is used in User Profile
@ -435,83 +499,97 @@ public class ContributionsFragment
fetchCampaigns();
}
}
mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI);
}
private void checkPermissionsAndShowNearbyCardView() {
if (PermissionUtils.hasPermission(getActivity(), Manifest.permission.ACCESS_FINE_LOCATION)) {
if (PermissionUtils.hasPermission(getActivity(), new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
onLocationPermissionGranted();
} else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) {
nearbyNotificationCardView.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
showNearbyCardPermissionRationale();
}
}
private void requestLocationPermission() {
PermissionUtils.checkPermissionsAndPerformAction(getActivity(),
Manifest.permission.ACCESS_FINE_LOCATION,
this::onLocationPermissionGranted,
this::displayYouWontSeeNearbyMessage,
-1,
-1);
nearbyLocationPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION});
}
private void onLocationPermissionGranted() {
nearbyNotificationCardView.permissionType = NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED;
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED;
locationManager.registerLocationManager();
}
private void showNearbyCardPermissionRationale() {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.nearby_card_permission_title),
getString(R.string.nearby_card_permission_explanation),
this::requestLocationPermission,
this::displayYouWontSeeNearbyMessage,
checkBoxView,
false);
getString(R.string.nearby_card_permission_title),
getString(R.string.nearby_card_permission_explanation),
this::requestLocationPermission,
this::displayYouWontSeeNearbyMessage,
checkBoxView,
false);
}
private void displayYouWontSeeNearbyMessage() {
ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.unable_to_display_nearest_place));
ViewUtil.showLongToast(getActivity(),
getResources().getString(R.string.unable_to_display_nearest_place));
// Set to true as the user doesn't want the app to ask for location permission anymore
store.putBoolean("doNotAskForLocationPermission", true);
}
private void updateClosestNearbyCardViewInfo() {
curLatLng = locationManager.getLastLocation();
currentLatLng = locationManager.getLastLocation();
compositeDisposable.add(Observable.fromCallable(() -> nearbyController
.loadAttractionsFromLocation(curLatLng, curLatLng, true, false, false)) // thanks to boolean, it will only return closest result
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateNearbyNotification,
throwable -> {
Timber.d(throwable);
updateNearbyNotification(null);
}));
.loadAttractionsFromLocation(currentLatLng, currentLatLng, true,
false)) // thanks to boolean, it will only return closest result
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateNearbyNotification,
throwable -> {
Timber.d(throwable);
updateNearbyNotification(null);
}));
}
private void updateNearbyNotification(@Nullable NearbyController.NearbyPlacesInfo nearbyPlacesInfo) {
if (nearbyPlacesInfo != null && nearbyPlacesInfo.placeList != null && nearbyPlacesInfo.placeList.size() > 0) {
Place closestNearbyPlace = nearbyPlacesInfo.placeList.get(0);
String distance = formatDistanceBetween(curLatLng, closestNearbyPlace.location);
closestNearbyPlace.setDistance(distance);
nearbyNotificationCardView.updateContent(closestNearbyPlace);
private void updateNearbyNotification(
@Nullable NearbyController.NearbyPlacesInfo nearbyPlacesInfo) {
if (nearbyPlacesInfo != null && nearbyPlacesInfo.placeList != null
&& nearbyPlacesInfo.placeList.size() > 0) {
Place closestNearbyPlace = null;
// Find the first nearby place that has no image and exists
for (Place place : nearbyPlacesInfo.placeList) {
if (place.pic.equals("") && place.exists) {
closestNearbyPlace = place;
break;
}
}
if (closestNearbyPlace == null) {
binding.cardViewNearby.setVisibility(View.GONE);
} else {
String distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location);
closestNearbyPlace.setDistance(distance);
direction = (float) computeBearing(currentLatLng, closestNearbyPlace.location);
binding.cardViewNearby.updateContent(closestNearbyPlace);
}
} else {
// Means that no close nearby place is found
nearbyNotificationCardView.setVisibility(View.GONE);
binding.cardViewNearby.setVisibility(View.GONE);
}
// Prevent Nearby banner from appearing in Media Details, fixing bug https://github.com/commons-app/apps-android-commons/issues/4731
if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) {
nearbyNotificationCardView.setVisibility(View.GONE);
binding.cardViewNearby.setVisibility(View.GONE);
}
}
@Override
public void onDestroy() {
try{
try {
compositeDisposable.clear();
getChildFragmentManager().removeOnBackStackChangedListener(this);
locationManager.unregisterLocationManager();
@ -525,22 +603,17 @@ public class ContributionsFragment
@Override
public void onLocationChangedSignificantly(LatLng latLng) {
// Will be called if location changed more than 1000 meter
// Do nothing on slight changes for using network efficiently
firstLocationUpdate = false;
updateClosestNearbyCardViewInfo();
}
@Override
public void onLocationChangedSlightly(LatLng latLng) {
/* Update closest nearby notification card onLocationChangedSlightly
If first time to update location after onResume, then no need to wait for significant
location change. Any closest location is better than no location
*/
if (firstLocationUpdate) {
*/
try {
updateClosestNearbyCardViewInfo();
// Turn it to false, since it is not first location update anymore. To change closest location
// notification, we need to wait for a significant location change.
firstLocationUpdate = false;
} catch (Exception e) {
Timber.e(e);
}
}
@ -550,7 +623,8 @@ public class ContributionsFragment
updateClosestNearbyCardViewInfo();
}
@Override public void onViewCreated(@NonNull View view,
@Override
public void onViewCreated(@NonNull View view,
@Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
}
@ -562,26 +636,35 @@ public class ContributionsFragment
*/
private void fetchCampaigns() {
if (Utils.isMonumentsEnabled(new Date())) {
campaignView.setCampaign(wlmCampaign);
campaignView.setVisibility(View.VISIBLE);
if (binding!=null) {
binding.campaignsView.setCampaign(wlmCampaign);
binding.campaignsView.setVisibility(View.VISIBLE);
}
} else if (store.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) {
presenter.getCampaigns();
} else {
campaignView.setVisibility(View.GONE);
if (binding!=null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
}
@Override public void showMessage(String message) {
@Override
public void showMessage(String message) {
Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
}
@Override public void showCampaigns(Campaign campaign) {
@Override
public void showCampaigns(Campaign campaign) {
if (campaign != null && !isUserProfile) {
campaignView.setCampaign(campaign);
if (binding!=null) {
binding.campaignsView.setCampaign(campaign);
}
}
}
@Override public void onDestroyView() {
@Override
public void onDestroyView() {
super.onDestroyView();
presenter.onDetachView();
}
@ -593,6 +676,17 @@ public class ContributionsFragment
}
}
/**
* Restarts the upload process for a contribution
*
* @param contribution
*/
public void restartUpload(Contribution contribution) {
contribution.setState(Contribution.STATE_QUEUED);
contributionsPresenter.saveContribution(contribution);
Timber.d("Restarting for %s", contribution.toString());
}
/**
* Retry upload when it is failed
*
@ -601,10 +695,25 @@ public class ContributionsFragment
@Override
public void retryUpload(Contribution contribution) {
if (NetworkUtils.isInternetConnectionEstablished(getContext())) {
if (contribution.getState() == STATE_FAILED || contribution.getState() == STATE_PAUSED || contribution.getState()==Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) {
contribution.setState(Contribution.STATE_QUEUED);
contributionsPresenter.saveContribution(contribution);
Timber.d("Restarting for %s", contribution.toString());
if (contribution.getState() == STATE_PAUSED
|| contribution.getState() == Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) {
restartUpload(contribution);
} else if (contribution.getState() == STATE_FAILED) {
int retries = contribution.getRetries();
// TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562
/* Limit the number of retries for a failed upload
to handle cases like invalid filename as such uploads
will never be successful */
if (retries < MAX_RETRIES) {
contribution.setRetries(retries + 1);
Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(),
retries + 1);
restartUpload(contribution);
} else {
// TODO: Show the exact reason for failure
Toast.makeText(getContext(),
R.string.retry_limit_reached, Toast.LENGTH_SHORT).show();
}
} else {
Timber.d("Skipping re-upload for non-failed %s", contribution.toString());
}
@ -616,6 +725,7 @@ public class ContributionsFragment
/**
* Pauses the upload
*
* @param contribution
*/
@Override
@ -639,15 +749,15 @@ public class ContributionsFragment
/**
* Replace whatever is in the current contributionsFragmentContainer view with
* mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects a
* contribution.
* mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects
* a contribution.
*/
@Override
public void showDetail(int position, boolean isWikipediaButtonDisplayed) {
if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) {
mediaDetailPagerFragment = new MediaDetailPagerFragment(false, true);
if(isUserProfile) {
((ProfileActivity)getActivity()).setScroll(false);
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true);
if (isUserProfile) {
((ProfileActivity) getActivity()).setScroll(false);
}
showMediaDetailPagerFragment();
}
@ -672,24 +782,26 @@ public class ContributionsFragment
public boolean backButtonClicked() {
if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) {
if (store.getBoolean("displayNearbyCardView", true) && !isUserProfile) {
if (nearbyNotificationCardView.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
nearbyNotificationCardView.setVisibility(View.VISIBLE);
if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
nearbyNotificationCardView.setVisibility(View.GONE);
binding.cardViewNearby.setVisibility(View.GONE);
}
removeFragment(mediaDetailPagerFragment);
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, mediaDetailPagerFragment);
if(isUserProfile) {
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
if (isUserProfile) {
// Fragment is associated with ProfileActivity
// Enable ParentViewPager Scroll
((ProfileActivity)getActivity()).setScroll(true);
}else {
((ProfileActivity) getActivity()).setScroll(true);
} else {
fetchCampaigns();
}
if (getActivity() instanceof MainActivity) {
// Fragment is associated with MainActivity
((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false);
((BaseActivity) getActivity()).getSupportActionBar()
.setDisplayHomeAsUpEnabled(false);
((MainActivity) getActivity()).showTabs();
}
return true;
@ -709,11 +821,11 @@ public class ContributionsFragment
void upDateUploadCount() {
WorkManager.getInstance(getContext())
.getWorkInfosForUniqueWorkLiveData(UploadWorker.class.getSimpleName()).observe(
getViewLifecycleOwner(), workInfos -> {
if (workInfos.size() > 0) {
setUploadCount();
}
});
getViewLifecycleOwner(), workInfos -> {
if (workInfos.size() > 0) {
setUploadCount();
}
});
}
@ -724,29 +836,40 @@ public class ContributionsFragment
*/
@Override
public void refreshNominatedMedia(int index) {
if(mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) {
if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) {
removeFragment(mediaDetailPagerFragment);
mediaDetailPagerFragment = new MediaDetailPagerFragment(false, true);
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true);
mediaDetailPagerFragment.showImage(index);
showMediaDetailPagerFragment();
}
}
// click listener to toggle description that means uses can press the limited connection
// banner and description will hide. Tap again to show description.
private View.OnClickListener toggleDescriptionListener = new View.OnClickListener() {
// click listener to toggle description that means uses can press the limited connection
// banner and description will hide. Tap again to show description.
private View.OnClickListener toggleDescriptionListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
View view2 = limitedConnectionDescriptionTextView;
if (view2.getVisibility() == View.GONE) {
view2.setVisibility(View.VISIBLE);
} else {
view2.setVisibility(View.GONE);
}
}
};
@Override
public void onClick(View view) {
View view2 = binding.limitedConnectionDescriptionTextView;
if (view2.getVisibility() == View.GONE) {
view2.setVisibility(View.VISIBLE);
} else {
view2.setVisibility(View.GONE);
}
}
};
/**
* When the device rotates, rotate the Nearby banner's compass arrow in tandem.
*/
@Override
public void onSensorChanged(SensorEvent event) {
float rotateDegree = Math.round(event.values[0]);
binding.cardViewNearby.rotateCompass(rotateDegree, direction);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// Nothing to do.
}
}

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.contributions;
import fr.free.nrw.commons.BasePresenter;
import java.util.List;
/**
* The contract for Contributions list View & Presenter

View file

@ -4,6 +4,7 @@ import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE;
import android.Manifest.permission;
import android.content.Context;
import android.content.res.Configuration;
import android.net.Uri;
@ -16,11 +17,12 @@ import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -28,26 +30,25 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import androidx.recyclerview.widget.SimpleItemAnimator;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.databinding.FragmentContributionsListBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.media.MediaClient;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.dataclient.WikiSite;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.wikidata.model.WikiSite;
/**
@ -60,63 +61,72 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private static final String RV_STATE = "rv_scroll_state";
@BindView(R.id.contributionsList)
RecyclerView rvContributionsList;
@BindView(R.id.loadingContributionsProgressBar)
ProgressBar progressBar;
@BindView(R.id.fab_plus)
FloatingActionButton fabPlus;
@BindView(R.id.fab_camera)
FloatingActionButton fabCamera;
@BindView(R.id.fab_gallery)
FloatingActionButton fabGallery;
@BindView(R.id.noContributionsYet)
TextView noContributionsYet;
@BindView(R.id.fab_layout)
LinearLayout fab_layout;
@BindView(R.id.fab_custom_gallery)
FloatingActionButton fabCustomGallery;
@Inject
SystemThemeUtils systemThemeUtils;
@BindView(R.id.tv_contributions_of_user)
AppCompatTextView tvContributionsOfUser;
@Inject
ContributionController controller;
@Inject
MediaClient mediaClient;
@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
@Inject
WikiSite languageWikipediaSite;
@Inject
ContributionsListPresenter contributionsListPresenter;
@Inject
SessionManager sessionManager;
private FragmentContributionsListBinding binding;
private Animation fab_close;
private Animation fab_open;
private Animation rotate_forward;
private Animation rotate_backward;
private boolean isFabOpen;
private ContributionsListAdapter adapter;
@VisibleForTesting
protected RecyclerView rvContributionsList;
@Nullable private Callback callback;
@VisibleForTesting
protected ContributionsListAdapter adapter;
@Nullable
@VisibleForTesting
protected Callback callback;
private final int SPAN_COUNT_LANDSCAPE = 3;
private final int SPAN_COUNT_PORTRAIT = 1;
private int contributionsSize;
String userName;
private String userName;
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
controller.locationPermissionCallback.onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
controller.handleShowRationaleFlowCameraLocation(getActivity(),
inAppCameraLocationPermissionLauncher);
} else {
controller.locationPermissionCallback.onLocationPermissionDenied(
getActivity().getString(
R.string.in_app_camera_location_permission_denied));
}
}
}
});
@Override
public void onCreate(@Nullable @org.jetbrains.annotations.Nullable final Bundle savedInstanceState) {
public void onCreate(
@Nullable @org.jetbrains.annotations.Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Now that we are allowing this fragment to be started for
// any userName- we expect it to be passed as an argument
@ -133,21 +143,36 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
public View onCreateView(
final LayoutInflater inflater, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
final View view = inflater.inflate(R.layout.fragment_contributions_list, container, false);
ButterKnife.bind(this, view);
binding = FragmentContributionsListBinding.inflate(
inflater, container, false
);
rvContributionsList = binding.contributionsList;
contributionsListPresenter.onAttachView(this);
binding.fabCustomGallery.setOnClickListener(v -> launchCustomSelector());
binding.fabCustomGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.custom_selector_title);
return true;
});
if (Objects.equals(sessionManager.getUserName(), userName)) {
tvContributionsOfUser.setVisibility(GONE);
fab_layout.setVisibility(VISIBLE);
binding.tvContributionsOfUser.setVisibility(GONE);
binding.fabLayout.setVisibility(VISIBLE);
} else {
tvContributionsOfUser.setVisibility(VISIBLE);
tvContributionsOfUser.setText(getString(R.string.contributions_of_user, userName));
fab_layout.setVisibility(GONE);
binding.tvContributionsOfUser.setVisibility(VISIBLE);
binding.tvContributionsOfUser.setText(getString(R.string.contributions_of_user, userName));
binding.fabLayout.setVisibility(GONE);
}
initAdapter();
return view;
return binding.getRoot();
}
@Override
public void onDestroyView() {
binding = null;
super.onDestroyView();
}
@Override
@ -280,7 +305,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
public void onConfigurationChanged(final Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// check orientation
fab_layout.setOrientation(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ?
binding.fabLayout.setOrientation(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ?
LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);
rvContributionsList
.setLayoutManager(
@ -295,22 +320,29 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
}
private void setListeners() {
fabPlus.setOnClickListener(view -> animateFAB(isFabOpen));
fabCamera.setOnClickListener(view -> {
controller.initiateCameraPick(getActivity());
binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen));
binding.fabCamera.setOnClickListener(view -> {
controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher);
animateFAB(isFabOpen);
});
fabGallery.setOnClickListener(view -> {
binding.fabCamera.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.add_contribution_from_camera);
return true;
});
binding.fabGallery.setOnClickListener(view -> {
controller.initiateGalleryPick(getActivity(), true);
animateFAB(isFabOpen);
});
binding.fabGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.menu_from_gallery);
return true;
});
}
/**
* Launch Custom Selector.
*/
@OnClick(R.id.fab_custom_gallery)
void launchCustomSelector(){
protected void launchCustomSelector() {
controller.initiateCustomGalleryPickWithPermission(getActivity());
animateFAB(isFabOpen);
}
@ -321,25 +353,25 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private void animateFAB(final boolean isFabOpen) {
this.isFabOpen = !isFabOpen;
if (fabPlus.isShown()) {
if (isFabOpen) {
fabPlus.startAnimation(rotate_backward);
fabCamera.startAnimation(fab_close);
fabGallery.startAnimation(fab_close);
fabCustomGallery.startAnimation(fab_close);
fabCamera.hide();
fabGallery.hide();
fabCustomGallery.hide();
} else {
fabPlus.startAnimation(rotate_forward);
fabCamera.startAnimation(fab_open);
fabGallery.startAnimation(fab_open);
fabCustomGallery.startAnimation(fab_open);
fabCamera.show();
fabGallery.show();
fabCustomGallery.show();
}
this.isFabOpen = !isFabOpen;
if (binding.fabPlus.isShown()) {
if (isFabOpen) {
binding.fabPlus.startAnimation(rotate_backward);
binding.fabCamera.startAnimation(fab_close);
binding.fabGallery.startAnimation(fab_close);
binding.fabCustomGallery.startAnimation(fab_close);
binding.fabCamera.hide();
binding.fabGallery.hide();
binding.fabCustomGallery.hide();
} else {
binding.fabPlus.startAnimation(rotate_forward);
binding.fabCamera.startAnimation(fab_open);
binding.fabGallery.startAnimation(fab_open);
binding.fabCustomGallery.startAnimation(fab_open);
binding.fabCamera.show();
binding.fabGallery.show();
binding.fabCustomGallery.show();
}
this.isFabOpen = !isFabOpen;
}
}
@ -348,7 +380,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
*/
@Override
public void showWelcomeTip(final boolean shouldShow) {
noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
}
/**
@ -358,12 +390,12 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
*/
@Override
public void showProgress(final boolean shouldShow) {
progressBar.setVisibility(shouldShow ? VISIBLE : GONE);
binding.loadingContributionsProgressBar.setVisibility(shouldShow ? VISIBLE : GONE);
}
@Override
public void showNoContributionsUI(final boolean shouldShow) {
noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
}
@Override
@ -393,14 +425,15 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
@Override
public void deleteUpload(final Contribution contribution) {
DialogUtil.showAlertDialog(getActivity(),
String.format(getString(R.string.cancelling_upload),
Locale.getDefault().getDisplayLanguage()),
String.format(getString(R.string.cancel_upload_dialog),
Locale.getDefault().getDisplayLanguage()),
"YES", "NO",
String.format(Locale.getDefault(),
getString(R.string.cancelling_upload)),
String.format(Locale.getDefault(),
getString(R.string.cancel_upload_dialog)),
String.format(Locale.getDefault(), getString(R.string.yes)), String.format(Locale.getDefault(), getString(R.string.no)),
() -> {
ViewUtil.showShortToast(getContext(), R.string.cancelling_upload);
contributionsListPresenter.deleteUpload(contribution);
CommonsApplication.cancelledUploads.add(contribution.getPageId());
}, () -> {
// Do nothing
});
@ -422,8 +455,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
public void addImageToWikipedia(Contribution contribution) {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.add_picture_to_wikipedia_article_title),
String.format(getString(R.string.add_picture_to_wikipedia_article_desc),
Locale.getDefault().getDisplayLanguage()),
getString(R.string.add_picture_to_wikipedia_article_desc),
() -> {
showAddImageToWikipediaInstructions(contribution);
}, () -> {

View file

@ -1,18 +1,12 @@
package fr.free.nrw.commons.contributions;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import fr.free.nrw.commons.MediaDataExtractor;
import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import fr.free.nrw.commons.upload.worker.UploadWorker;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.functions.Action;
import io.reactivex.functions.Consumer;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
@ -76,11 +70,7 @@ public class ContributionsPresenter implements UserActionListener {
compositeDisposable.add(repository
.save(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe(() -> {
WorkManager.getInstance(view.getContext().getApplicationContext())
.enqueueUniqueWork(
UploadWorker.class.getSimpleName(),
ExistingWorkPolicy.KEEP, OneTimeWorkRequest.from(UploadWorker.class));
}));
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(
view.getContext().getApplicationContext(), ExistingWorkPolicy.KEEP)));
}
}

View file

@ -1,28 +1,24 @@
package fr.free.nrw.commons.contributions;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.databinding.MainBinding;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.WelcomeActivity;
@ -45,9 +41,13 @@ import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.worker.UploadWorker;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
@ -59,14 +59,8 @@ public class MainActivity extends BaseActivity
SessionManager sessionManager;
@Inject
ContributionController controller;
@BindView(R.id.toolbar)
Toolbar toolbar;
@BindView(R.id.pager)
public UnswipableViewPager viewPager;
@BindView(R.id.fragmentContainer)
public FrameLayout fragmentContainer;
@BindView(R.id.fragment_main_nav_tab_layout)
NavTabLayout tabLayout;
@Inject
ContributionDao contributionDao;
private ContributionsFragment contributionsFragment;
private NearbyParentFragment nearbyParentFragment;
@ -91,6 +85,11 @@ public class MainActivity extends BaseActivity
public Menu menu;
public MainBinding binding;
NavTabLayout tabLayout;
/**
* Consumers should be simply using this method to use this activity.
*
@ -118,11 +117,13 @@ public class MainActivity extends BaseActivity
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = MainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarBinding.toolbar);
tabLayout = binding.fragmentMainNavTabLayout;
loadLocale();
setContentView(R.layout.main);
ButterKnife.bind(this);
setSupportActionBar(toolbar);
toolbar.setNavigationOnClickListener(view -> {
binding.toolbarBinding.toolbar.setNavigationOnClickListener(view -> {
onSupportNavigateUp();
});
/*
@ -139,6 +140,10 @@ public class MainActivity extends BaseActivity
setTitle(getString(R.string.navigation_item_explore));
setUpLoggedOutPager();
} else {
if (applicationKvStore.getBoolean("firstrun", true)) {
applicationKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", false);
applicationKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", false);
}
if(savedInstanceState == null){
//starting a fresh fragment.
// Open Last opened screen if it is Contributions or Nearby, otherwise Contributions
@ -152,15 +157,29 @@ public class MainActivity extends BaseActivity
}
}
setUpPager();
/**
* Ask the user for media location access just after login
* so that location in the EXIF metadata of the images shared by the user
* is retained on devices running Android 10 or above
*/
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
PermissionUtils.checkPermissionsAndPerformAction(
this,
() -> {},
R.string.media_location_permission_denied,
R.string.add_location_manually,
permission.ACCESS_MEDIA_LOCATION);
}
checkAndResumeStuckUploads();
}
}
public void setSelectedItemId(int id) {
tabLayout.setSelectedItemId(id);
binding.fragmentMainNavTabLayout.setSelectedItemId(id);
}
private void setUpPager() {
tabLayout.setOnNavigationItemSelectedListener(navListener = (item) -> {
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(navListener = (item) -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
@ -175,7 +194,7 @@ public class MainActivity extends BaseActivity
private void setUpLoggedOutPager() {
loadFragment(ExploreFragment.newInstance(),false);
tabLayout.setOnNavigationItemSelectedListener(item -> {
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(item -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
@ -237,11 +256,11 @@ public class MainActivity extends BaseActivity
}
public void hideTabs() {
tabLayout.setVisibility(View.GONE);
binding.fragmentMainNavTabLayout.setVisibility(View.GONE);
}
public void showTabs() {
tabLayout.setVisibility(View.VISIBLE);
binding.fragmentMainNavTabLayout.setVisibility(View.VISIBLE);
}
/**
@ -259,6 +278,34 @@ public class MainActivity extends BaseActivity
}
}
/**
* Resume the uploads that got stuck because of the app being killed
* or the device being rebooted.
*
* When the app is terminated or the device is restarted, contributions remain in the
* 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events.
* So, retrieving contributions labeled as 'STATE_IN_PROGRESS'
* from the database will provide the list of uploads that appear as stuck on opening the app again
*/
@SuppressLint("CheckResult")
private void checkAndResumeStuckUploads() {
List<Contribution> stuckUploads = contributionDao.getContribution(
Collections.singletonList(Contribution.STATE_IN_PROGRESS))
.subscribeOn(Schedulers.io())
.blockingGet();
Timber.d("Resuming " + stuckUploads.size() + " uploads...");
if(!stuckUploads.isEmpty()) {
for(Contribution contribution: stuckUploads) {
contribution.setState(Contribution.STATE_QUEUED);
Completable.fromAction(() -> contributionDao.saveSynchronous(contribution))
.subscribeOn(Schedulers.io())
.subscribe();
}
WorkRequestHelper.Companion.makeOneTimeWorkRequest(
this, ExistingWorkPolicy.APPEND_OR_REPLACE);
}
}
@Override
protected void onPostCreate(@Nullable Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
@ -268,7 +315,7 @@ public class MainActivity extends BaseActivity
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt("viewPagerCurrentItem", viewPager.getCurrentItem());
outState.putInt("viewPagerCurrentItem", binding.pager.getCurrentItem());
outState.putString("activeFragment", activeFragment.name());
}
@ -347,6 +394,21 @@ public class MainActivity extends BaseActivity
}
}
/**
* Retry all failed uploads as soon as the user returns to the app
*/
@SuppressLint("CheckResult")
private void retryAllFailedUploads() {
contributionDao.
getContribution(Collections.singletonList(Contribution.STATE_FAILED))
.subscribeOn(Schedulers.io())
.subscribe(failedUploads -> {
for (Contribution contribution: failedUploads) {
contributionsFragment.retryUpload(contribution);
}
});
}
public void toggleLimitedConnectionMode() {
defaultKvStore.putBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED,
!defaultKvStore
@ -356,10 +418,8 @@ public class MainActivity extends BaseActivity
viewUtilWrapper
.showShortToast(getBaseContext(), getString(R.string.limited_connection_enabled));
} else {
WorkManager.getInstance(getApplicationContext()).enqueueUniqueWork(
UploadWorker.class.getSimpleName(),
ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequest.from(UploadWorker.class));
WorkRequestHelper.Companion.makeOneTimeWorkRequest(getApplicationContext(),
ExistingWorkPolicy.APPEND_OR_REPLACE);
viewUtilWrapper
.showShortToast(getBaseContext(), getString(R.string.limited_connection_disabled));
}
@ -368,8 +428,6 @@ public class MainActivity extends BaseActivity
public void centerMapToPlace(Place place) {
setSelectedItemId(NavTab.NEARBY.code());
nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback(new NearbyParentFragmentInstanceReadyCallback() {
// if mapBox initialize in nearbyParentFragment then MapReady() function called
// so that nearbyParentFragemt.centerMaptoPlace(place) not throw any null pointer exception
@Override
public void onReady() {
nearbyParentFragment.centerMapToPlace(place);
@ -390,8 +448,11 @@ public class MainActivity extends BaseActivity
if ((applicationKvStore.getBoolean("firstrun", true)) &&
(!applicationKvStore.getBoolean("login_skipped"))) {
defaultKvStore.putBoolean("inAppCameraFirstRun", true);
WelcomeActivity.startYourself(this);
}
retryAllFailedUploads();
}
@Override
@ -407,7 +468,7 @@ public class MainActivity extends BaseActivity
* Public method to show nearby from the reference of this.
*/
public void showNearby() {
tabLayout.setSelectedItemId(NavTab.NEARBY.code());
binding.fragmentMainNavTabLayout.setSelectedItemId(NavTab.NEARBY.code());
}
public enum ActiveFragment {

View file

@ -0,0 +1,130 @@
package fr.free.nrw.commons.contributions;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.WallpaperManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.facebook.common.executors.CallerThreadExecutor;
import com.facebook.common.references.CloseableReference;
import com.facebook.datasource.DataSource;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imagepipeline.core.ImagePipeline;
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber;
import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.R;
import java.io.IOException;
import timber.log.Timber;
public class SetWallpaperWorker extends Worker {
private static final String NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel";
private static final int NOTIFICATION_ID = 1;
public SetWallpaperWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
Context context = getApplicationContext();
createNotificationChannel(context);
showProgressNotification(context);
String imageUrl = getInputData().getString("imageUrl");
if (imageUrl == null) {
return Result.failure();
}
ImageRequest imageRequest = ImageRequestBuilder
.newBuilderWithSource(Uri.parse(imageUrl))
.build();
ImagePipeline imagePipeline = Fresco.getImagePipeline();
final DataSource<CloseableReference<CloseableImage>>
dataSource = imagePipeline.fetchDecodedImage(imageRequest, context);
dataSource.subscribe(new BaseBitmapDataSubscriber() {
@Override
public void onNewResultImpl(@Nullable Bitmap bitmap) {
if (dataSource.isFinished() && bitmap != null) {
Timber.d("Bitmap loaded from url %s", imageUrl.toString());
setWallpaper(context, Bitmap.createBitmap(bitmap));
dataSource.close();
}
}
@Override
public void onFailureImpl(DataSource dataSource) {
Timber.d("Error getting bitmap from image url %s", imageUrl.toString());
showNotification(context, "Setting Wallpaper Failed", "Failed to download image.");
if (dataSource != null) {
dataSource.close();
}
}
}, CallerThreadExecutor.getInstance());
return Result.success();
}
private void setWallpaper(Context context, Bitmap bitmap) {
WallpaperManager wallpaperManager = WallpaperManager.getInstance(context);
try {
wallpaperManager.setBitmap(bitmap);
showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.");
} catch (Exception e) {
Timber.e(e, "Error setting wallpaper");
showNotification(context, "Setting Wallpaper Failed", " "+e.getLocalizedMessage());
}
}
private void showProgressNotification(Context context) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle("Setting Wallpaper")
.setContentText("Please wait...")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setProgress(0, 0, true);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
private void showNotification(Context context, String title, String content) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(false);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = "Wallpaper Setting";
String description = "Notifications for wallpaper setting progress";
int importance = NotificationManager.IMPORTANCE_HIGH;
NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance);
channel.setDescription(description);
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel);
}
}
}

View file

@ -16,7 +16,7 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
private val SWIPE_THRESHOLD_WIDTH = (getScreenResolution(context!!)).first / 3
private val SWIPE_VELOCITY_THRESHOLD = 1000
override fun onTouch(view: View?, motionEvent: MotionEvent?): Boolean {
override fun onTouch(view: View?, motionEvent: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(motionEvent)
}
@ -32,7 +32,7 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent?): Boolean {
override fun onDown(e: MotionEvent): Boolean {
return true
}

View file

@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY
@ -85,6 +86,8 @@ class ImageAdapter(
*/
private var actionableImagesMap: TreeMap<Int, Image> = TreeMap()
private var uploadingContributionList: List<Contribution> = ArrayList()
/**
* Stores already added positions of actionable images
*/
@ -119,6 +122,7 @@ class ImageAdapter(
* Bind View holder, load image, selected view, click listeners.
*/
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
var image=images[position]
holder.image.setImageDrawable (null)
if (context.contentResolver.getType(image.uri) == null) {
@ -151,13 +155,12 @@ class ImageAdapter(
val isSelected = selectedIndex != -1
if (isSelected) {
holder.itemSelected(selectedImages.size)
holder.itemSelected()
} else {
holder.itemUnselected()
}
imageLoader.queryAndSetView(
holder, image, ioDispatcher, defaultDispatcher
holder, image, ioDispatcher, defaultDispatcher ,uploadingContributionList
)
scope.launch {
val sharedPreferences: SharedPreferences =
@ -168,15 +171,17 @@ class ImageAdapter(
// If the position is not already visited, that means the position is new then
// finds the next actionable image position from all images
if (!alreadyAddedPositions.contains(position)) {
processThumbnailForActionedImage(holder, position)
processThumbnailForActionedImage(holder, position, uploadingContributionList)
// If the position is already visited, that means the image is already present
// inside map, so it will fetch the image from the map and load in the holder
} else {
val actionableImages: List<Image> = ArrayList(actionableImagesMap.values)
image = actionableImages[position]
Glide.with(holder.image).load(image.uri)
.thumbnail(0.3f).into(holder.image)
if(actionableImages.size > position) {
image = actionableImages[position]
Glide.with(holder.image).load(image.uri)
.thumbnail(0.3f).into(holder.image)
}
}
// If switch is turned off, it just fetches the image from all images without any
@ -204,11 +209,12 @@ class ImageAdapter(
*/
suspend fun processThumbnailForActionedImage(
holder: ImageViewHolder,
position: Int
position: Int,
uploadingContributionList: List<Contribution>
) {
val next = imageLoader.nextActionableImage(
allImages, ioDispatcher, defaultDispatcher,
nextImagePosition
nextImagePosition, uploadingContributionList
)
// If next actionable image is found, saves it, as the the search for
@ -328,12 +334,13 @@ class ImageAdapter(
/**
* Initialize the data set.
*/
fun init(newImages: List<Image>, fixedImages: List<Image>, emptyMap: TreeMap<Int, Image>) {
fun init(newImages: List<Image>, fixedImages: List<Image>, emptyMap: TreeMap<Int, Image>, uploadedImages: List<Contribution> = ArrayList()) {
allImages = fixedImages
val oldImageList:ArrayList<Image> = images
val newImageList:ArrayList<Image> = ArrayList(newImages)
actionableImagesMap = emptyMap
alreadyAddedPositions = ArrayList()
uploadingContributionList = uploadedImages
nextImagePosition = 0
reachedEndOfFolder = false
selectedImages = ArrayList()
@ -355,15 +362,56 @@ class ImageAdapter(
/**
* Refresh the data in the adapter
*/
fun refresh(newImages: List<Image>, fixedImages: List<Image>) {
fun refresh(newImages: List<Image>, fixedImages: List<Image>, uploadingImages: List<Contribution> = ArrayList()) {
numberOfSelectedImagesMarkedAsNotForUpload = 0
selectedImages.clear()
images.clear()
selectedImages = arrayListOf()
init(newImages, fixedImages, TreeMap())
init(newImages, fixedImages, TreeMap(),uploadingImages)
notifyDataSetChanged()
}
/**
* Clear selected images and empty the list.
*/
fun clearSelectedImages(){
numberOfSelectedImagesMarkedAsNotForUpload = 0
selectedImages.clear()
selectedImages = arrayListOf()
}
/**
* Remove image from actionable images map.
*/
fun removeImageFromActionableImageMap(image: Image) {
val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val showAlreadyActionedImages =
sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
if(showAlreadyActionedImages) {
refresh(allImages, allImages, uploadingContributionList)
} else {
val iterator = actionableImagesMap.entries.iterator()
var index = 0
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value == image) {
imagePositionAsPerIncreasingOrder -= 1
iterator.remove()
alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1)
notifyItemRemoved(index)
notifyItemRangeChanged(index, itemCount )
break
}
index++
}
}
}
/**
* Returns the total number of items in the data set held by the adapter.
*
@ -407,17 +455,16 @@ class ImageAdapter(
*/
class ImageViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
val image: ImageView = itemView.findViewById(R.id.image_thumbnail)
private val selectedNumber: TextView = itemView.findViewById(R.id.selected_count)
private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group)
private val uploadingGroup: Group = itemView.findViewById(R.id.uploading_group)
private val notForUploadGroup: Group = itemView.findViewById(R.id.not_for_upload_group)
private val selectedGroup: Group = itemView.findViewById(R.id.selected_group)
/**
* Item selected view.
*/
fun itemSelected(index: Int) {
fun itemSelected() {
selectedGroup.visibility = View.VISIBLE
selectedNumber.text = index.toString()
}
/**
@ -434,6 +481,13 @@ class ImageAdapter(
uploadedGroup.visibility = View.VISIBLE
}
/**
* Item is uploading
*/
fun itemUploading() {
uploadingGroup.visibility = View.VISIBLE
}
/**
* Item is not for upload view
*/
@ -452,6 +506,13 @@ class ImageAdapter(
return notForUploadGroup.visibility == View.VISIBLE
}
/**
* Item is not uploading
*/
fun itemNotUploading() {
uploadingGroup.visibility = View.GONE
}
/**
* Item Not Uploaded view.
*/
@ -513,4 +574,4 @@ class ImageAdapter(
return images[position].date
}
}
}

View file

@ -30,6 +30,7 @@ import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils
import kotlinx.coroutines.*
import java.io.File
import java.lang.Integer.max
import javax.inject.Inject
@ -66,6 +67,22 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/
private lateinit var prefs: SharedPreferences
/**
* Maximum number of images that can be selected.
*/
private val uploadLimit: Int = 20
/**
* Flag that is marked true when the amount
* of selected images is greater than the upload limit.
*/
private var uploadLimitExceeded: Boolean = false
/**
* Tracks the amount by which the upload limit has been exceeded.
*/
private var uploadLimitExceededBy: Int = 0
/**
* View Model Factory.
*/
@ -95,6 +112,8 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/
var imageFragment: ImageFragment? = null
private var progressDialogText:String=""
/**
* onCreate Activity, sets theme, initialises the view model, setup view.
*/
@ -140,7 +159,7 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
data!!
.getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!!
val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false)
imageFragment!!.passSelectedImages(selectedImages, shouldRefresh)
imageFragment?.passSelectedImages(selectedImages, shouldRefresh)
}
}
@ -187,17 +206,18 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
markAsNotForUpload(arrayListOf())
return
}
var i = 0
while (i < selectedImages.size) {
val path = selectedImages[i].path
val iterator = selectedImages.iterator()
while (iterator.hasNext()) {
val image = iterator.next()
val path = image.path
val file = File(path)
if (!file.exists()) {
selectedImages.removeAt(i)
i--
iterator.remove()
}
i++
}
markAsNotForUpload(selectedImages)
toolbarBinding.imageLimitError.visibility = View.INVISIBLE
}
/**
@ -221,56 +241,63 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/
private fun insertIntoNotForUpload(images: ArrayList<Image>) {
scope.launch {
withContext(Dispatchers.Main) {
imageFragment?.showMarkUnmarkProgressDialog(text = progressDialogText)
}
var allImagesAlreadyNotForUpload = true
images.forEach {
images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
image.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
val exists = notForUploadStatusDao.find(imageSHA1)
// If image exists in not for upload table make allImagesAlreadyNotForUpload false
if (exists < 1) {
allImagesAlreadyNotForUpload = false
}
}
// if all images is not already marked as not for upload, insert all images in
// not for upload table
if (!allImagesAlreadyNotForUpload) {
images.forEach {
// Insert or delete images as necessary, but the UI updates should be posted back to the main thread
images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
image.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
notForUploadStatusDao.insert(
NotForUploadStatus(
imageSHA1
)
)
notForUploadStatusDao.insert(NotForUploadStatus(imageSHA1))
}
withContext(Dispatchers.Main) {
images.forEach { image ->
imageFragment?.removeImage(image)
}
imageFragment?.clearSelectedImages()
}
// if all images is already marked as not for upload, delete all images from
// not for upload table
} else {
images.forEach {
images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1(
it.uri,
image.uri,
ioDispatcher,
fileUtilsWrapper,
contentResolver
)
notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1)
}
withContext(Dispatchers.Main) {
imageFragment?.refresh()
}
}
imageFragment!!.refresh()
val bottomLayout: ConstraintLayout = findViewById(R.id.bottom_layout)
bottomLayout.visibility = View.GONE
withContext(Dispatchers.Main) {
imageFragment?.dismissMarkUnmarkProgressDialog()
val bottomLayout: ConstraintLayout = findViewById(R.id.bottom_layout)
bottomLayout.visibility = View.GONE
changeTitle(bucketName, 0)
}
}
}
@ -284,10 +311,17 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
/**
* Change the title of the toolbar.
*/
private fun changeTitle(title: String) {
val titleText = findViewById<TextView>(R.id.title)
if (titleText != null) {
titleText.text = title
private fun changeTitle(title: String, selectedImageCount:Int) {
if (title.isNotEmpty()){
val titleText = findViewById<TextView>(R.id.title)
var titleWithAppendedImageCount = title
if (selectedImageCount > 0) {
titleWithAppendedImageCount += " (${resources.getQuantityString(R.plurals.custom_picker_images_selected_title_appendix,
selectedImageCount, selectedImageCount)})"
}
if (titleText != null) {
titleText.text = titleWithAppendedImageCount
}
}
}
@ -297,6 +331,10 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
private fun setUpToolbar() {
val back: ImageButton = findViewById(R.id.back)
back.setOnClickListener { onBackPressed() }
val limitError: ImageButton = findViewById(R.id.image_limit_error)
limitError.visibility = View.INVISIBLE
limitError.setOnClickListener { displayUploadLimitWarning() }
}
/**
@ -308,7 +346,7 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
.addToBackStack(null)
.commit()
changeTitle(folderName)
changeTitle(folderName, 0)
bucketId = folderId
bucketName = folderName
@ -323,8 +361,21 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
selectedNotForUploadImages: Int
) {
viewModel.selectedImages.value = selectedImages
changeTitle(bucketName, selectedImages.size)
if (selectedNotForUploadImages > 0) {
uploadLimitExceeded = selectedImages.size > uploadLimit
uploadLimitExceededBy = max(selectedImages.size - uploadLimit,0)
if (uploadLimitExceeded && selectedNotForUploadImages == 0) {
toolbarBinding.imageLimitError.visibility = View.VISIBLE
bottomSheetBinding.upload.text = resources.getString(
R.string.custom_selector_button_limit_text, uploadLimit)
} else {
toolbarBinding.imageLimitError.visibility = View.INVISIBLE
bottomSheetBinding.upload.text = resources.getString(R.string.upload)
}
if (uploadLimitExceeded || selectedNotForUploadImages > 0) {
bottomSheetBinding.upload.isEnabled = false
bottomSheetBinding.upload.alpha = 0.5f
} else {
@ -334,8 +385,14 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
bottomSheetBinding.notForUpload.text =
when (selectedImages.size == selectedNotForUploadImages) {
true -> getString(R.string.unmark_as_not_for_upload)
else -> getString(R.string.mark_as_not_for_upload)
true -> {
progressDialogText=getString(R.string.unmarking_as_not_for_upload)
getString(R.string.unmark_as_not_for_upload)
}
else -> {
progressDialogText=getString(R.string.marking_as_not_for_upload)
getString(R.string.mark_as_not_for_upload)
}
}
val bottomLayout: ConstraintLayout = findViewById(R.id.bottom_layout)
@ -366,22 +423,22 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
* Get the selected images. Remove any non existent file, forward the data to finish selector.
*/
fun onDone() {
val selectedImages = viewModel.selectedImages.value
if (selectedImages.isNullOrEmpty()) {
finishPickImages(arrayListOf())
return
}
var i = 0
while (i < selectedImages.size) {
val path = selectedImages[i].path
val file = File(path)
if (!file.exists()) {
selectedImages.removeAt(i)
i--
val selectedImages = viewModel.selectedImages.value
if (selectedImages.isNullOrEmpty()) {
finishPickImages(arrayListOf())
return
}
i++
}
finishPickImages(selectedImages)
var i = 0
while (i < selectedImages.size) {
val path = selectedImages[i].path
val file = File(path)
if (!file.exists()) {
selectedImages.removeAt(i)
i--
}
i++
}
finishPickImages(selectedImages)
}
/**
@ -404,10 +461,24 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
if (fragment != null && fragment is FolderFragment) {
isImageFragmentOpen = false
changeTitle(getString(R.string.custom_selector_title))
changeTitle(getString(R.string.custom_selector_title), 0)
}
}
/**
* Displays a dialog explaining the upload limit warning.
*/
private fun displayUploadLimitWarning() {
val dialog = Dialog(this)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(R.layout.custom_selector_limit_dialog)
(dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener()
{ dialog.dismiss() }
(dialog.findViewById(R.id.upload_limit_warning) as TextView).text = resources.getString(
R.string.custom_selector_over_limit_warning, uploadLimit, uploadLimitExceededBy)
dialog.show()
}
/**
* On activity destroy
* If image fragment is open, overwrite its attributes otherwise discard the values.
@ -426,4 +497,4 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
const val FOLDER_NAME: String = "FolderName"
const val ITEM_ID: String = "ItemId"
}
}
}

View file

@ -93,7 +93,7 @@ class FolderFragment : CommonsDaggerSupportFragment() {
*/
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = FragmentCustomSelectorBinding.inflate(inflater, container, false)
folderAdapter = FolderAdapter(activity!!, activity as FolderClickListener)
folderAdapter = FolderAdapter(requireActivity(), activity as FolderClickListener)
gridLayoutManager = GridLayoutManager(context, columnCount())
selectorRV = binding?.selectorRv
loader = binding?.loader
@ -159,4 +159,4 @@ class FolderFragment : CommonsDaggerSupportFragment() {
return 2
// todo change column count depending on the orientation of the device.
}
}
}

View file

@ -11,7 +11,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.coroutines.CoroutineContext
/**
@ -90,6 +92,12 @@ class ImageFileLoader(val context: Context) : CoroutineScope{
}
if (file != null && file.exists() && name != null && path != null && bucketName != null) {
val extension = path.substringAfterLast(".", "")
// Check if the extension is one of the allowed types
if (extension.lowercase(Locale.ROOT) !in arrayOf("jpg", "jpeg", "png", "svg", "gif", "tiff", "webp", "xcf")) {
continue
}
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
val calendar = Calendar.getInstance()
@ -130,4 +138,4 @@ class ImageFileLoader(val context: Context) : CoroutineScope{
* Sha1 for image (original image).
*
*/
}
}

View file

@ -9,37 +9,41 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.Switch
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.listeners.PassDataListener
import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.listeners.PassDataListener
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
import fr.free.nrw.commons.customselector.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.customselector.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.databinding.ProgressDialogBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers
import java.util.*
import javax.inject.Inject
import kotlin.collections.ArrayList
/**
* Custom Selector Image Fragment.
*/
class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassDataListener {
class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDataListener {
private var _binding: FragmentCustomSelectorBinding? = null
private val binding get() = _binding
@ -57,7 +61,7 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
/**
* View model for images.
*/
private var viewModel: CustomSelectorViewModel? = null
private var viewModel: CustomSelectorViewModel? = null
/**
* View Elements.
@ -99,6 +103,10 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
*/
private var progressLayout: ConstraintLayout? = null
private lateinit var progressDialog: AlertDialog
private lateinit var progressDialogLayout: ProgressDialogBinding
/**
* NotForUploadStatus Dao class for database operations
*/
@ -129,6 +137,9 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
@Inject
lateinit var mediaClient: MediaClient
@Inject
lateinit var contributionDao: ContributionDao
companion object {
/**
@ -163,7 +174,9 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
super.onCreate(savedInstanceState)
bucketId = arguments?.getLong(BUCKET_ID)
lastItemId = arguments?.getLong(LAST_ITEM_ID, 0)
viewModel = ViewModelProvider(requireActivity(),customSelectorViewModelFactory).get(CustomSelectorViewModel::class.java)
viewModel = ViewModelProvider(requireActivity(), customSelectorViewModelFactory).get(
CustomSelectorViewModel::class.java
)
}
/**
@ -171,17 +184,22 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
* Init imageAdapter, gridLayoutManger.
* SetUp recycler view.
*/
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentCustomSelectorBinding.inflate(inflater, container, false)
imageAdapter = ImageAdapter(requireActivity(), activity as ImageSelectListener, imageLoader!!)
gridLayoutManager = GridLayoutManager(context,getSpanCount())
with(binding?.selectorRv){
imageAdapter =
ImageAdapter(requireActivity(), activity as ImageSelectListener, imageLoader!!)
gridLayoutManager = GridLayoutManager(context, getSpanCount())
with(binding?.selectorRv) {
this?.layoutManager = gridLayoutManager
this?.setHasFixedSize(true)
this?.adapter = imageAdapter
}
viewModel?.result?.observe(viewLifecycleOwner, Observer{
viewModel?.result?.observe(viewLifecycleOwner, Observer {
handleResult(it)
})
@ -194,9 +212,16 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
val sharedPreferences: SharedPreferences =
requireContext().getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, MODE_PRIVATE)
showAlreadyActionedImages = sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
showAlreadyActionedImages =
sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
switch?.isChecked = showAlreadyActionedImages
val builder = AlertDialog.Builder(requireActivity())
builder.setCancelable(false)
progressDialogLayout = ProgressDialogBinding.inflate(layoutInflater, container, false)
builder.setView(progressDialogLayout.root)
progressDialog = builder.create()
return binding?.root
}
@ -217,7 +242,8 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
editor.apply()
}
imageAdapter.init(allImages, allImages, TreeMap())
val uploadingContributions = getUploadingContributions()
imageAdapter.init(allImages, allImages, TreeMap(), uploadingContributions)
imageAdapter.notifyDataSetChanged()
}
@ -236,13 +262,15 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
/**
* Handle view model result.
*/
private fun handleResult(result:Result){
if(result.status is CallbackStatus.SUCCESS){
private fun handleResult(result: Result) {
if (result.status is CallbackStatus.SUCCESS) {
val images = result.images
if(images.isNotEmpty()) {
val uploadingContributions = getUploadingContributions()
if (images.isNotEmpty()) {
filteredImages = ImageHelper.filterImages(images, bucketId)
allImages = ArrayList(filteredImages)
imageAdapter.init(filteredImages, allImages, TreeMap())
imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions)
selectorRV?.let {
it.visibility = View.VISIBLE
lastItemId?.let { pos ->
@ -250,18 +278,18 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
.scrollToPosition(ImageHelper.getIndexFromId(filteredImages, pos))
}
}
}
else{
} else {
binding?.emptyText?.let {
it.visibility = View.VISIBLE
}
selectorRV?.let{
selectorRV?.let {
it.visibility = View.GONE
}
}
}
loader?.let {
it.visibility = if (result.status is CallbackStatus.FETCHING) View.VISIBLE else View.GONE
it.visibility =
if (result.status is CallbackStatus.FETCHING) View.VISIBLE else View.GONE
}
}
@ -317,19 +345,61 @@ class ImageFragment: CommonsDaggerSupportFragment(), RefreshUIListener, PassData
}
override fun refresh() {
imageAdapter.refresh(filteredImages, allImages)
imageAdapter.refresh(filteredImages, allImages, getUploadingContributions())
}
/**
* Removes the image from the actionable image map
*/
fun removeImage(image : Image){
imageAdapter.removeImageFromActionableImageMap(image)
}
/**
* Clears the selected images
*/
fun clearSelectedImages() {
imageAdapter.clearSelectedImages()
}
/**
* Passes selected images and other information from Activity to Fragment and connects it with
* the adapter
*/
override fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean){
override fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean) {
imageAdapter.setSelectedImages(selectedImages)
val uploadingContributions = getUploadingContributions()
if (!showAlreadyActionedImages && shouldRefresh) {
imageAdapter.init(filteredImages, allImages, TreeMap())
imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions)
imageAdapter.setSelectedImages(selectedImages)
}
}
}
/**
* Shows mark/unmark progress dialog
*/
fun showMarkUnmarkProgressDialog(text: String) {
if (!progressDialog.isShowing) {
progressDialogLayout.progressDialogText.text = text
progressDialog.show()
}
}
/**
* Dismisses mark/unmark progress dialog
*/
fun dismissMarkUnmarkProgressDialog() {
if (progressDialog.isShowing) {
progressDialog.dismiss()
}
}
private fun getUploadingContributions(): List<Contribution> {
return contributionDao.getContribution(
listOf(Contribution.STATE_IN_PROGRESS, Contribution.STATE_FAILED, Contribution.STATE_QUEUED, Contribution.STATE_PAUSED)
)?.subscribeOn(Schedulers.io())?.blockingGet() ?: emptyList()
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.customselector.ui.selector
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
@ -75,7 +76,8 @@ class ImageLoader @Inject constructor(
holder: ImageViewHolder,
image: Image,
ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher
defaultDispatcher: CoroutineDispatcher,
uploadedContributionsList : List<Contribution>
) {
/**
@ -84,6 +86,7 @@ class ImageLoader @Inject constructor(
mapHolderImage[holder] = image
holder.itemNotUploaded()
holder.itemForUpload()
holder.itemNotUploading()
scope.launch {
var result: Result = Result.NOTFOUND
@ -214,6 +217,17 @@ class ImageLoader @Inject constructor(
holder.itemNotForUpload()
} else holder.itemForUpload()
}
if (uploadedContributionsList.isNotEmpty()) {
for (contribution in uploadedContributionsList ) {
if (contribution.contentUri == image.uri && showAlreadyActionedImages) {
holder.itemUploading()
break
} else {
holder.itemNotUploading()
}
}
}
}
}
@ -223,17 +237,22 @@ class ImageLoader @Inject constructor(
suspend fun nextActionableImage(
allImages: List<Image>, ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher,
nextImagePosition: Int
nextImagePosition: Int,
currentlyUploadingImages: List<Contribution>
): Int {
var next: Int
// Traversing from given position to the end
for (i in nextImagePosition until allImages.size){
val it = allImages[i]
val imageSHA1: String = when (mapImageSHA1[it.uri] != null) {
true -> mapImageSHA1[it.uri]!!
val currentImage = allImages[i]
if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) {
continue // Skip this image as it's currently being uploaded
}
val imageSHA1: String = when (mapImageSHA1[currentImage.uri] != null) {
true -> mapImageSHA1[currentImage.uri]!!
else -> CustomSelectorUtils.getImageSHA1(
it.uri,
currentImage.uri,
ioDispatcher,
fileUtilsWrapper,
context.contentResolver
@ -253,7 +272,7 @@ class ImageLoader @Inject constructor(
// If the image is not present in the already uploaded table, checks for its
// modified SHA1 in already uploaded table
if (next <= 0) {
val modifiedImageSha1 = getSHA1(it, defaultDispatcher)
val modifiedImageSha1 = getSHA1(currentImage, defaultDispatcher)
next = uploadedStatusDao.findByModifiedImageSHA1(
modifiedImageSha1,
true
@ -360,4 +379,4 @@ class ImageLoader @Inject constructor(
const val INVALIDATE_DAY_COUNT: Long = 7
}
}
}

View file

@ -6,6 +6,8 @@ import androidx.room.TypeConverters
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.*
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.PlaceDao
import fr.free.nrw.commons.review.ReviewDao
import fr.free.nrw.commons.review.ReviewEntity
import fr.free.nrw.commons.upload.depicts.Depicts
@ -15,10 +17,11 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao
* The database for accessing the respective DAOs
*
*/
@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class], version = 15, exportSchema = false)
@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], version = 18, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun contributionDao(): ContributionDao
abstract fun PlaceDao(): PlaceDao
abstract fun DepictsDao(): DepictsDao;
abstract fun UploadedStatusDao(): UploadedStatusDao;
abstract fun NotForUploadStatusDao(): NotForUploadStatusDao

View file

@ -8,8 +8,10 @@ import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.contributions.ChunkInfo;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Sitelinks;
import fr.free.nrw.commons.upload.WikidataPlace;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.lang.reflect.Type;
import java.util.Date;
import java.util.List;
import java.util.Map;
@ -134,6 +136,18 @@ public class Converters {
return readObjectWithTypeToken(depictedItems, new TypeToken<List<DepictedItem>>() {});
}
@TypeConverter
public static Sitelinks sitelinksFromString(String value) {
Type type = new TypeToken<Sitelinks>() {}.getType();
return new Gson().fromJson(value, type);
}
@TypeConverter
public static String fromSitelinks(Sitelinks sitelinks) {
Gson gson = new Gson();
return gson.toJson(sitelinks);
}
private static String writeObjectToString(Object object) {
return object == null ? null : getGson().toJson(object);
}

View file

@ -13,6 +13,7 @@ import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
@ -66,7 +67,13 @@ public class DeleteHelper {
return delete(media, reason)
.flatMapSingle(result -> Single.just(showDeletionNotification(context, media, result)))
.firstOrError();
.firstOrError()
.onErrorResumeNext(throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
return Single.error(throwable);
}
return Single.error(throwable);
});
}
/**
@ -104,22 +111,30 @@ public class DeleteHelper {
}
return pageEditClient.prependEdit(media.getFilename(), fileDeleteString + "\n", summary)
.flatMap(result -> {
if (result) {
return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary);
}
throw new RuntimeException("Failed to nominate for deletion");
}).flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("Commons:Deletion_requests/" + date, logPageString + "\n", summary);
}
throw new RuntimeException("Failed to nominate for deletion");
}).flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("User_Talk:" + creator, userPageString + "\n", summary);
}
throw new RuntimeException("Failed to nominate for deletion");
});
.onErrorResumeNext(throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
return Observable.error(throwable);
}
return Observable.error(throwable);
})
.flatMap(result -> {
if (result) {
return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
})
.flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("Commons:Deletion_requests/" + date, logPageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
})
.flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("User_Talk:" + creator, userPageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
});
}
private boolean showDeletionNotification(Context context, Media media, boolean result) {
@ -205,6 +220,8 @@ public class DeleteHelper {
});
alert.setPositiveButton(context.getString(R.string.ok), (dialogInterface, i) -> {
reviewCallback.disableButtons();
String reason = getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_alert_set_positive_button_reason) + " ";
@ -224,13 +241,15 @@ public class DeleteHelper {
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(aBoolean -> {
if (aBoolean) {
reviewCallback.onSuccess();
reviewCallback.onSuccess();
}, throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
reviewCallback.onTokenException((InvalidLoginTokenException) throwable);
} else {
reviewCallback.onFailure();
}
reviewCallback.enableButtons();
});
});
alert.setNegativeButton(context.getString(R.string.cancel), (dialog, which) -> reviewCallback.onFailure());
d = alert.create();

View file

@ -2,7 +2,7 @@ package fr.free.nrw.commons.delete;
import android.content.Context;
import org.wikipedia.util.DateUtil;
import fr.free.nrw.commons.utils.DateUtil;
import java.util.Date;
import java.util.Locale;

View file

@ -1,16 +1,21 @@
package fr.free.nrw.commons.description
import android.app.ProgressDialog
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.speech.RecognizerIntent
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.databinding.ActivityDescriptionEditBinding
import fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION
import fr.free.nrw.commons.description.EditDescriptionConstants.UPDATED_WIKITEXT
import fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.settings.Prefs
@ -18,8 +23,13 @@ import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.UploadMediaDetail
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.Consumer
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import javax.inject.Inject
/**
* Activity for populating and editing existing description and caption
*/
@ -40,6 +50,11 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
*/
var wikiText: String? = null
/**
* Media object
*/
var media: Media? = null
/**
* Saved language
*/
@ -55,6 +70,15 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
private lateinit var binding: ActivityDescriptionEditBinding
private val REQUEST_CODE_FOR_VOICE_INPUT = 1213
private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null
@Inject lateinit var descriptionEditHelper: DescriptionEditHelper
@Inject lateinit var sessionManager: SessionManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -62,13 +86,21 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
setContentView(binding.root)
val bundle = intent.extras
val descriptionAndCaptions: ArrayList<UploadMediaDetail> =
bundle!!.getParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION)!!
wikiText = bundle.getString(WIKITEXT)
savedLanguageValue = bundle.getString(Prefs.DESCRIPTION_LANGUAGE)!!
if (savedInstanceState != null) {
descriptionAndCaptions = savedInstanceState.getParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION)
wikiText = savedInstanceState.getString(WIKITEXT)
savedLanguageValue = savedInstanceState.getString(Prefs.DESCRIPTION_LANGUAGE)!!
media = savedInstanceState.getParcelable("media")
} else {
descriptionAndCaptions =
bundle!!.getParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION)!!
wikiText = bundle.getString(WIKITEXT)
savedLanguageValue = bundle.getString(Prefs.DESCRIPTION_LANGUAGE)!!
media = bundle.getParcelable("media")
}
initRecyclerView(descriptionAndCaptions)
binding.btnAddDescription.setOnClickListener(::onButtonAddDescriptionClicked)
binding.btnEditSubmit.setOnClickListener(::onSubmitButtonClicked)
binding.toolbarBackButton.setOnClickListener(::onBackButtonClicked)
}
@ -78,7 +110,7 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
* @param descriptionAndCaptions list of description and caption
*/
private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) {
uploadMediaDetailAdapter = UploadMediaDetailAdapter(
uploadMediaDetailAdapter = UploadMediaDetailAdapter(this,
savedLanguageValue, descriptionAndCaptions, recentLanguagesDao)
uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int ->
showInfoAlert(
@ -107,17 +139,20 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
override fun onPrimaryCaptionTextChange(isNotEmpty: Boolean) {}
private fun onBackButtonClicked(view: View) {
onBackPressed()
}
private fun onButtonAddDescriptionClicked(view: View) {
/**
* Adds new language item to RecyclerView
*/
override fun addLanguage() {
val uploadMediaDetail = UploadMediaDetail()
uploadMediaDetail.isManuallyAdded = true //This was manually added by the user
uploadMediaDetailAdapter.addDescription(uploadMediaDetail)
rvDescriptions!!.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1)
}
private fun onBackButtonClicked(view: View) {
onBackPressedDispatcher.onBackPressed()
}
private fun onSubmitButtonClicked(view: View) {
showLoggingProgressBar()
val uploadMediaDetails = uploadMediaDetailAdapter.items
@ -151,22 +186,85 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
buffer.append(uploadDetails.languageCode)
buffer.append("|1=")
buffer.append(uploadDetails.descriptionText)
buffer.append("}}, ")
buffer.append("}}")
}
}
buffer.replace(", $".toRegex(), "")
buffer.append(descriptionEnd)
}
val returningIntent = Intent()
returningIntent.putExtra(UPDATED_WIKITEXT, buffer.toString())
returningIntent.putParcelableArrayListExtra(
LIST_OF_DESCRIPTION_AND_CAPTION,
uploadMediaDetails as ArrayList<out Parcelable?>
)
setResult(RESULT_OK, returningIntent)
editDescription(media!!, buffer.toString(), uploadMediaDetails as ArrayList<UploadMediaDetail>)
finish()
}
/**
* Edits description and caption
* @param media media object
* @param updatedWikiText updated wiki text
* @param uploadMediaDetails descriptions and captions
*/
private fun editDescription(media : Media, updatedWikiText : String, uploadMediaDetails : ArrayList<UploadMediaDetail>){
try {
descriptionEditHelper?.addDescription(
applicationContext, media,
updatedWikiText
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(Consumer<Boolean> { s: Boolean? -> Timber.d("Descriptions are added.") })?.let {
compositeDisposable.add(
it
)
}
} catch (e : InvalidLoginTokenException) {
val username: String? = sessionManager?.userName
val logoutListener = CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
)
val commonsApplication = CommonsApplication.getInstance()
if (commonsApplication != null ){
commonsApplication.clearApplicationData(this,logoutListener)
}
}
val updatedCaptions = LinkedHashMap<String, String>()
for (mediaDetail in uploadMediaDetails) {
try {
compositeDisposable.add(
descriptionEditHelper!!.addCaption(
applicationContext, media,
mediaDetail.languageCode, mediaDetail.captionText
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { s: Boolean? ->
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
media.captions = updatedCaptions
Timber.d("Caption is added.")
})
}
catch (e : InvalidLoginTokenException) {
val username = sessionManager.userName
val logoutListener = CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
)
val commonsApplication = CommonsApplication.getInstance()
if (commonsApplication != null ){
commonsApplication.clearApplicationData(this,logoutListener)
}
}
}
}
private fun showLoggingProgressBar() {
progressDialog = ProgressDialog(this)
progressDialog!!.isIndeterminate = true
@ -175,4 +273,24 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
progressDialog!!.setCanceledOnTouchOutside(false)
progressDialog!!.show()
}
}
override
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_FOR_VOICE_INPUT) {
if (resultCode == RESULT_OK && data != null) {
val result = data.getStringArrayListExtra( RecognizerIntent.EXTRA_RESULTS )
uploadMediaDetailAdapter.handleSpeechResult(result!![0]) }
else { Timber.e("Error %s", resultCode) }
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, uploadMediaDetailAdapter.items as ArrayList<out Parcelable?>)
outState.putString(WIKITEXT, wikiText)
outState.putString(Prefs.DESCRIPTION_LANGUAGE, savedLanguageValue)
//save Media
outState.putParcelable("media", media)
}
}

View file

@ -14,6 +14,7 @@ import fr.free.nrw.commons.description.DescriptionEditActivity;
import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity;
import fr.free.nrw.commons.explore.SearchActivity;
import fr.free.nrw.commons.media.ZoomableActivity;
import fr.free.nrw.commons.nearby.WikidataFeedback;
import fr.free.nrw.commons.notification.NotificationActivity;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.review.ReviewActivity;
@ -79,4 +80,7 @@ public abstract class ActivityBuilderModule {
@ContributesAndroidInjector
abstract ZoomableActivity bindZoomableActivity();
@ContributesAndroidInjector
abstract WikidataFeedback bindWikiFeedback();
}

View file

@ -2,10 +2,11 @@ package fr.free.nrw.commons.di;
import com.google.gson.Gson;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.explore.categories.CategoriesModule;
import fr.free.nrw.commons.navtab.MoreBottomSheetFragment;
import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment;
import fr.free.nrw.commons.navtab.NavTabLayout;
import fr.free.nrw.commons.nearby.NearbyController;
import fr.free.nrw.commons.upload.worker.UploadWorker;
import javax.inject.Singleton;
@ -69,6 +70,9 @@ public interface CommonsApplicationComponent extends AndroidInjector<Application
void inject(PicOfDayAppWidget picOfDayAppWidget);
@Singleton
void inject(NearbyController nearbyController);
Gson gson();
@Component.Builder

View file

@ -24,6 +24,7 @@ import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.PlaceDao;
import fr.free.nrw.commons.review.ReviewDao;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.UploadController;
@ -41,7 +42,6 @@ import java.util.Map;
import java.util.Objects;
import javax.inject.Named;
import javax.inject.Singleton;
import org.wikipedia.AppAdapter;
/**
* The Dependency Provider class for Commons Android.
@ -257,8 +257,8 @@ public class CommonsApplicationModule {
@Named("username")
@Provides
public String provideLoggedInUsername() {
return Objects.toString(AppAdapter.get().getUserName(), "");
public String provideLoggedInUsername(SessionManager sessionManager) {
return Objects.toString(sessionManager.getUserName(), "");
}
@Provides
@ -276,6 +276,11 @@ public class CommonsApplicationModule {
return appDatabase.contributionDao();
}
@Provides
public PlaceDao providesPlaceDao(AppDatabase appDatabase) {
return appDatabase.PlaceDao();
}
/**
* Get the reference of DepictsDao class.
*/

View file

@ -7,8 +7,16 @@ import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.BetaConstants;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.OkHttpConnectionFactory;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.actions.PageEditInterface;
import fr.free.nrw.commons.actions.ThanksInterface;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.auth.csrf.CsrfTokenInterface;
import fr.free.nrw.commons.auth.csrf.LogoutClient;
import fr.free.nrw.commons.auth.login.LoginClient;
import fr.free.nrw.commons.auth.login.LoginInterface;
import fr.free.nrw.commons.category.CategoryInterface;
import fr.free.nrw.commons.explore.depictions.DepictsClient;
import fr.free.nrw.commons.kvstore.JsonKvStore;
@ -18,11 +26,15 @@ import fr.free.nrw.commons.media.PageMediaInterface;
import fr.free.nrw.commons.media.WikidataMediaInterface;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.mwapi.UserInterface;
import fr.free.nrw.commons.notification.NotificationInterface;
import fr.free.nrw.commons.review.ReviewInterface;
import fr.free.nrw.commons.upload.UploadInterface;
import fr.free.nrw.commons.upload.WikiBaseInterface;
import fr.free.nrw.commons.upload.depicts.DepictsInterface;
import fr.free.nrw.commons.wikidata.CommonsServiceFactory;
import fr.free.nrw.commons.wikidata.WikidataInterface;
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar;
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage;
import java.io.File;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@ -33,31 +45,25 @@ import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import okhttp3.logging.HttpLoggingInterceptor.Level;
import org.wikipedia.csrf.CsrfTokenClient;
import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.json.GsonUtil;
import org.wikipedia.login.LoginClient;
import fr.free.nrw.commons.wikidata.model.WikiSite;
import fr.free.nrw.commons.wikidata.GsonUtil;
import timber.log.Timber;
@Module
@SuppressWarnings({"WeakerAccess", "unused"})
public class NetworkingModule {
private static final String WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql";
private static final String TOOLS_FORGE_URL = "https://tools.wmflabs.org/urbanecmbot/commonsmisc";
private static final String TEST_TOOLS_FORGE_URL = "https://tools.wmflabs.org/commons-android-app/tool-commons-android-app";
private static final String TOOLS_FORGE_URL = "https://tools.wmflabs.org/commons-android-app/tool-commons-android-app";
public static final long OK_HTTP_CACHE_SIZE = 10 * 1024 * 1024;
public static final String NAMED_COMMONS_WIKI_SITE = "commons-wikisite";
private static final String NAMED_WIKI_DATA_WIKI_SITE = "wikidata-wikisite";
private static final String NAMED_WIKI_PEDIA_WIKI_SITE = "wikipedia-wikisite";
public static final String NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE = "language-wikipedia-wikisite";
public static final String NAMED_COMMONS_CSRF = "commons-csrf";
public static final String NAMED_WIKI_CSRF = "wiki-csrf";
@Provides
@Singleton
@ -73,6 +79,12 @@ public class NetworkingModule {
.build();
}
@Provides
@Singleton
public CommonsServiceFactory serviceFactory(CommonsCookieJar cookieJar) {
return new CommonsServiceFactory(OkHttpConnectionFactory.getClient(cookieJar));
}
@Provides
@Singleton
public HttpLoggingInterceptor provideHttpLoggingInterceptor() {
@ -88,29 +100,86 @@ public class NetworkingModule {
public OkHttpJsonApiClient provideOkHttpJsonApiClient(OkHttpClient okHttpClient,
DepictsClient depictsClient,
@Named("tools_forge") HttpUrl toolsForgeUrl,
@Named("test_tools_forge") HttpUrl testToolsForgeUrl,
@Named("default_preferences") JsonKvStore defaultKvStore,
Gson gson) {
return new OkHttpJsonApiClient(okHttpClient,
depictsClient,
toolsForgeUrl,
testToolsForgeUrl,
WIKIDATA_SPARQL_QUERY_URL,
BuildConfig.WIKIMEDIA_CAMPAIGNS_URL,
gson);
}
@Named(NAMED_COMMONS_CSRF)
@Provides
@Singleton
public CsrfTokenClient provideCommonsCsrfTokenClient(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return new CsrfTokenClient(commonsWikiSite, commonsWikiSite);
public CommonsCookieStorage provideCookieStorage(
@Named("default_preferences") JsonKvStore preferences) {
CommonsCookieStorage cookieStorage = new CommonsCookieStorage(preferences);
cookieStorage.load();
return cookieStorage;
}
@Provides
@Singleton
public LoginClient provideLoginClient() {
return new LoginClient();
public CommonsCookieJar provideCookieJar(CommonsCookieStorage storage) {
return new CommonsCookieJar(storage);
}
@Named(NAMED_COMMONS_CSRF)
@Provides
@Singleton
public CsrfTokenClient provideCommonsCsrfTokenClient(SessionManager sessionManager,
@Named("commons-csrf-interface") CsrfTokenInterface tokenInterface, LoginClient loginClient, LogoutClient logoutClient) {
return new CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient);
}
/**
* Provides a singleton instance of CsrfTokenClient for Wikidata.
*
* @param sessionManager The session manager to manage user sessions.
* @param tokenInterface The interface for obtaining CSRF tokens.
* @param loginClient The client for handling login operations.
* @param logoutClient The client for handling logout operations.
* @return A singleton instance of CsrfTokenClient.
*/
@Named(NAMED_WIKI_CSRF)
@Provides
@Singleton
public CsrfTokenClient provideWikiCsrfTokenClient(SessionManager sessionManager,
@Named("wikidata-csrf-interface") CsrfTokenInterface tokenInterface, LoginClient loginClient, LogoutClient logoutClient) {
return new CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient);
}
/**
* Provides a singleton instance of CsrfTokenInterface for Wikidata.
*
* @param serviceFactory The factory used to create service interfaces.
* @return A singleton instance of CsrfTokenInterface for Wikidata.
*/
@Named("wikidata-csrf-interface")
@Provides
@Singleton
public CsrfTokenInterface provideWikidataCsrfTokenInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.WIKIDATA_URL, CsrfTokenInterface.class);
}
@Named("commons-csrf-interface")
@Provides
@Singleton
public CsrfTokenInterface provideCsrfTokenInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, CsrfTokenInterface.class);
}
@Provides
@Singleton
public LoginInterface provideLoginInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, LoginInterface.class);
}
@Provides
@Singleton
public LoginClient provideLoginClient(LoginInterface loginInterface) {
return new LoginClient(loginInterface);
}
@Provides
@ -129,21 +198,6 @@ public class NetworkingModule {
return HttpUrl.parse(TOOLS_FORGE_URL);
}
@Provides
@Named("test_tools_forge")
@NonNull
@SuppressWarnings("ConstantConditions")
public HttpUrl provideTestToolsForgeUrl() {
return HttpUrl.parse(TEST_TOOLS_FORGE_URL);
}
@Provides
@Singleton
@Named(NAMED_COMMONS_WIKI_SITE)
public WikiSite provideCommonsWikiSite() {
return new WikiSite(BuildConfig.COMMONS_URL);
}
@Provides
@Singleton
@Named(NAMED_WIKI_DATA_WIKI_SITE)
@ -164,54 +218,40 @@ public class NetworkingModule {
@Provides
@Singleton
@Named("commons-service")
public Service provideCommonsService(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite);
public ReviewInterface provideReviewInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, ReviewInterface.class);
}
@Provides
@Singleton
@Named("wikidata-service")
public Service provideWikidataService(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikidataWikiSite) {
return ServiceFactory.get(wikidataWikiSite, BuildConfig.WIKIDATA_URL, Service.class);
public DepictsInterface provideDepictsInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.WIKIDATA_URL, DepictsInterface.class);
}
@Provides
@Singleton
public ReviewInterface provideReviewInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, ReviewInterface.class);
public WikiBaseInterface provideWikiBaseInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, WikiBaseInterface.class);
}
@Provides
@Singleton
public DepictsInterface provideDepictsInterface(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikidataWikiSite) {
return ServiceFactory.get(wikidataWikiSite, BuildConfig.WIKIDATA_URL, DepictsInterface.class);
}
@Provides
@Singleton
public WikiBaseInterface provideWikiBaseInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, WikiBaseInterface.class);
}
@Provides
@Singleton
public UploadInterface provideUploadInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, UploadInterface.class);
public UploadInterface provideUploadInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, UploadInterface.class);
}
@Named("commons-page-edit-service")
@Provides
@Singleton
public PageEditInterface providePageEditService(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, PageEditInterface.class);
public PageEditInterface providePageEditService(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, PageEditInterface.class);
}
@Named("wikidata-page-edit-service")
@Provides
@Singleton
public PageEditInterface provideWikiDataPageEditService(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikiDataWikiSite) {
return ServiceFactory.get(wikiDataWikiSite, BuildConfig.WIKIDATA_URL, PageEditInterface.class);
public PageEditInterface provideWikiDataPageEditService(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.WIKIDATA_URL, PageEditInterface.class);
}
@Named("commons-page-edit")
@ -222,10 +262,25 @@ public class NetworkingModule {
return new PageEditClient(csrfTokenClient, pageEditInterface);
}
/**
* Provides a singleton instance of PageEditClient for Wikidata.
*
* @param csrfTokenClient The client used to manage CSRF tokens.
* @param pageEditInterface The interface for page edit operations.
* @return A singleton instance of PageEditClient for Wikidata.
*/
@Named("wikidata-page-edit")
@Provides
@Singleton
public MediaInterface provideMediaInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, MediaInterface.class);
public PageEditClient provideWikidataPageEditClient(@Named(NAMED_WIKI_CSRF) CsrfTokenClient csrfTokenClient,
@Named("wikidata-page-edit-service") PageEditInterface pageEditInterface) {
return new PageEditClient(csrfTokenClient, pageEditInterface);
}
@Provides
@Singleton
public MediaInterface provideMediaInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, MediaInterface.class);
}
/**
@ -236,36 +291,44 @@ public class NetworkingModule {
*/
@Provides
@Singleton
public WikidataMediaInterface provideWikidataMediaInterface(
@Named(NAMED_COMMONS_WIKI_SITE) final WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite,
BetaConstants.COMMONS_URL, WikidataMediaInterface.class);
public WikidataMediaInterface provideWikidataMediaInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BetaConstants.COMMONS_URL, WikidataMediaInterface.class);
}
@Provides
@Singleton
public MediaDetailInterface providesMediaDetailInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikisite) {
return ServiceFactory.get(commonsWikisite, BuildConfig.COMMONS_URL, MediaDetailInterface.class);
public MediaDetailInterface providesMediaDetailInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, MediaDetailInterface.class);
}
@Provides
@Singleton
public CategoryInterface provideCategoryInterface(
@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory
.get(commonsWikiSite, BuildConfig.COMMONS_URL, CategoryInterface.class);
public CategoryInterface provideCategoryInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, CategoryInterface.class);
}
@Provides
@Singleton
public UserInterface provideUserInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, UserInterface.class);
public ThanksInterface provideThanksInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, ThanksInterface.class);
}
@Provides
@Singleton
public WikidataInterface provideWikidataInterface(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikiDataWikiSite) {
return ServiceFactory.get(wikiDataWikiSite, BuildConfig.WIKIDATA_URL, WikidataInterface.class);
public NotificationInterface provideNotificationInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, NotificationInterface.class);
}
@Provides
@Singleton
public UserInterface provideUserInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.COMMONS_URL, UserInterface.class);
}
@Provides
@Singleton
public WikidataInterface provideWikidataInterface(CommonsServiceFactory serviceFactory) {
return serviceFactory.create(BuildConfig.WIKIDATA_URL, WikidataInterface.class);
}
/**
@ -274,8 +337,8 @@ public class NetworkingModule {
*/
@Provides
@Singleton
public PageMediaInterface providePageMediaInterface(@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) WikiSite wikiSite) {
return ServiceFactory.get(wikiSite, wikiSite.url(), PageMediaInterface.class);
public PageMediaInterface providePageMediaInterface(@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) WikiSite wikiSite, CommonsServiceFactory serviceFactory) {
return serviceFactory.create(wikiSite.url(), PageMediaInterface.class);
}
@Provides

View file

@ -0,0 +1,301 @@
package fr.free.nrw.commons.edit
import android.animation.Animator
import android.animation.Animator.AnimatorListener
import android.animation.ValueAnimator
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.os.Bundle
import android.util.Log
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.rotationMatrix
import androidx.core.graphics.scaleMatrix
import androidx.core.net.toUri
import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.databinding.ActivityEditBinding
import timber.log.Timber
import java.io.File
/**
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
*
* This activity allows loads an image, allows users to rotate it by 90-degree increments, and
* save the edited image while preserving its EXIF attributes. The class includes methods
* for initializing the UI, animating image rotations, copying EXIF data, and handling
* the image-saving process.
*/
class EditActivity : AppCompatActivity() {
private var imageUri = ""
private lateinit var vm: EditViewModel
private val sourceExifAttributeList = mutableListOf<Pair<String, String?>>()
private lateinit var binding: ActivityEditBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityEditBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.title = ""
val intent = intent
imageUri = intent.getStringExtra("image") ?: ""
vm = ViewModelProvider(this).get(EditViewModel::class.java)
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
val exifTags = arrayOf(
ExifInterface.TAG_APERTURE,
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_EXPOSURE_TIME,
ExifInterface.TAG_FLASH,
ExifInterface.TAG_FOCAL_LENGTH,
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_ISO,
ExifInterface.TAG_MAKE,
ExifInterface.TAG_MODEL,
ExifInterface.TAG_ORIENTATION,
ExifInterface.TAG_WHITE_BALANCE,
ExifInterface.WHITEBALANCE_AUTO,
ExifInterface.WHITEBALANCE_MANUAL
)
for (tag in exifTags) {
val attribute = sourceExif?.getAttribute(tag.toString())
sourceExifAttributeList.add(Pair(tag.toString(), attribute))
}
init()
}
/**
* Initializes the ImageView and associated UI elements.
*
* This function sets up the ImageView for displaying an image, adjusts its view bounds,
* and scales the initial image to fit within the ImageView. It also sets click listeners
* for the "Rotate" and "Save" buttons.
*/
private fun init() {
binding.iv.adjustViewBounds = true
binding.iv.scaleType = ImageView.ScaleType.MATRIX
binding.iv.post(Runnable {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(imageUri, options)
val bitmapWidth = options.outWidth
val bitmapHeight = options.outHeight
// Check if the bitmap dimensions exceed a certain threshold
val maxBitmapSize = 2000 // Set your maximum size here
if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) {
val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize)
options.inSampleSize = scaleFactor
options.inJustDecodeBounds = false
val scaledBitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(scaledBitmap)
// Update the ImageView with the scaled bitmap
val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat()
binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
} else {
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(bitmap)
val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat()
binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
}
})
binding.rotateBtn.setOnClickListener {
animateImageHeight()
}
binding.btnSave.setOnClickListener {
getRotatedImage()
}
}
var imageRotation = 0
/**
* Animates the height, rotation, and scale of an ImageView to provide a smooth
* transition effect when rotating an image by 90 degrees.
*
* This function calculates the new height, rotation, and scale for the ImageView
* based on the current image rotation angle and animates the changes using a
* ValueAnimator. It also disables a rotate button during the animation to prevent
* further rotation actions.
*/
private fun animateImageHeight() {
val drawableWidth: Float = binding.iv.getDrawable().getIntrinsicWidth().toFloat()
val drawableHeight: Float = binding.iv.getDrawable().getIntrinsicHeight().toFloat()
val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat()
val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat()
val rotation = imageRotation % 360
val newRotation = rotation + 90
val newViewHeight: Int
val imageScale: Float
val newImageScale: Float
Timber.d("Rotation $rotation")
Timber.d("new Rotation $newRotation")
if (rotation == 0 || rotation == 180) {
imageScale = viewWidth / drawableWidth
newImageScale = viewWidth / drawableHeight
newViewHeight = (drawableWidth * newImageScale).toInt()
} else if (rotation == 90 || rotation == 270) {
imageScale = viewWidth / drawableHeight
newImageScale = viewWidth / drawableWidth
newViewHeight = (drawableHeight * newImageScale).toInt()
} else {
throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported")
}
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
animator.interpolator = AccelerateDecelerateInterpolator()
animator.addListener(object : AnimatorListener {
override fun onAnimationStart(animation: Animator) {
binding.rotateBtn.setEnabled(false)
}
override fun onAnimationEnd(animation: Animator) {
imageRotation = newRotation % 360
binding.rotateBtn.setEnabled(true)
}
override fun onAnimationCancel(animation: Animator) {
}
override fun onAnimationRepeat(animation: Animator) {
}
})
animator.addUpdateListener { animation ->
val animVal = animation.animatedValue as Float
val complementaryAnimVal = 1 - animVal
val animatedHeight =
(complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt()
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
binding.iv.getLayoutParams().height = animatedHeight
val matrix: Matrix = rotationMatrix(
animatedRotation,
drawableWidth / 2,
drawableHeight / 2
)
matrix.postScale(
animatedScale,
animatedScale,
drawableWidth / 2,
drawableHeight / 2
)
matrix.postTranslate(
-(drawableWidth - binding.iv.getMeasuredWidth()) / 2,
-(drawableHeight - binding.iv.getMeasuredHeight()) / 2
)
binding.iv.setImageMatrix(matrix)
binding.iv.requestLayout()
}
animator.start()
}
/**
* Rotates and edits the current image, copies EXIF data, and returns the edited image path.
*
* This function retrieves the path of the current image specified by `imageUri`,
* rotates it based on the `imageRotation` angle using the `rotateImage` method
* from the `vm`, and updates the EXIF attributes of the
* rotated image based on the `sourceExifAttributeList`. It then copies the EXIF data
* using the `copyExifData` method, creates an Intent to return the edited image's file path
* as a result, and finishes the current activity.
*/
fun getRotatedImage() {
val filePath = imageUri.toUri().path
val file = filePath?.let { File(it) }
val rotatedImage = file?.let { vm.rotateImage(imageRotation, it) }
if (rotatedImage == null) {
Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show()
}
val editedImageExif: ExifInterface?
if (rotatedImage?.path != null) {
editedImageExif = ExifInterface(rotatedImage.path)
copyExifData(editedImageExif)
}
val resultIntent = Intent()
resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error");
setResult(RESULT_OK, resultIntent);
finish();
}
/**
* Copies EXIF data from sourceExifAttributeList to the provided ExifInterface object.
*
* This function iterates over the `sourceExifAttributeList` and sets the EXIF attributes
* on the provided `editedImageExif` object.
*
* @param editedImageExif The ExifInterface object for the edited image.
*/
private fun copyExifData(editedImageExif: ExifInterface?) {
for (attr in sourceExifAttributeList) {
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
editedImageExif!!.setAttribute(attr.first, attr.second)
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
}
editedImageExif?.saveAttributes()
}
/**
* Calculates the scale factor to be used for scaling down a bitmap based on its original
* dimensions and the maximum allowed size.
* @param originalWidth The original width of the bitmap.
* @param originalHeight The original height of the bitmap.
* @param maxSize The maximum allowed size for either width or height.
* @return The scale factor to be used for scaling down the bitmap.
* If the bitmap is smaller than or equal to the maximum size in both dimensions,
* the scale factor is 1.
* If the bitmap is larger than the maximum size in either dimension,
* the scale factor is calculated as the largest power of 2 that is less than or equal
* to the ratio of the original dimension to the maximum size.
* The scale factor ensures that the scaled bitmap will fit within the maximum size
* while maintaining aspect ratio.
*/
private fun calculateScaleFactor(originalWidth: Int, originalHeight: Int, maxSize: Int): Int {
var scaleFactor = 1
if (originalWidth > maxSize || originalHeight > maxSize) {
// Calculate the largest power of 2 that is less than or equal to the desired width and height
val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt()
val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt()
scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio
}
return scaleFactor
}
}

View file

@ -0,0 +1,27 @@
package fr.free.nrw.commons.edit
import androidx.lifecycle.ViewModel
import java.io.File
/**
* ViewModel for image editing operations.
*
* This ViewModel class is responsible for managing image editing operations, such as
* rotating images. It utilizes a TransformImage implementation to perform image transformations.
*/
class EditViewModel() : ViewModel() {
// Ideally should be injected using DI
private val transformImage: TransformImage = TransformImageImpl()
/**
* Rotates the specified image file by the given degree.
*
* @param degree The degree by which to rotate the image.
* @param imageFile The File representing the image to be rotated.
* @return The rotated image File, or null if the rotation operation fails.
*/
fun rotateImage(degree: Int, imageFile: File): File? {
return transformImage.rotateImage(imageFile, degree)
}
}

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.edit
import java.io.File
/**
* Interface for image transformation operations.
*
* This interface defines a contract for image transformation operations, allowing
* implementations to provide specific functionality for tasks like rotating images.
*/
interface TransformImage {
/**
* Rotates the specified image file by the given degree.
*
* @param imageFile The File representing the image to be rotated.
* @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails.
*/
fun rotateImage(imageFile: File, degree : Int ):File?
}

View file

@ -0,0 +1,74 @@
package fr.free.nrw.commons.edit
import android.mediautil.image.jpeg.LLJTran
import android.mediautil.image.jpeg.LLJTranException
import android.os.Environment
import timber.log.Timber
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
/**
* Implementation of the TransformImage interface for image rotation operations.
*
* This class provides an implementation for the TransformImage interface, right now it exposes a
* function for rotating images by a specified degree using the LLJTran library. Right now it reads
* the input image file, performs the rotation, and saves the rotated image to a new file.
*/
class TransformImageImpl() : TransformImage {
/**
* Rotates the specified image file by the given degree.
*
* @param imageFile The File representing the image to be rotated.
* @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails.
*/
override fun rotateImage(imageFile: File, degree : Int): File? {
Timber.tag("Trying to rotate image").d("Starting")
val path = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
)
val imagePath = System.currentTimeMillis()
val file: File = File(path, "$imagePath.jpg")
val output = file
val rotated = try {
val lljTran = LLJTran(imageFile)
lljTran.read(
LLJTran.READ_ALL,
false,
) // This could throw an LLJTranException. I am not catching it for now... Let's see.
lljTran.transform(
when(degree){
90 -> LLJTran.ROT_90
180 -> LLJTran.ROT_180
270 -> LLJTran.ROT_270
else -> {
LLJTran.ROT_90
}
},
LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION
)
BufferedOutputStream(FileOutputStream(output)).use { writer ->
lljTran.save(writer, LLJTran.OPT_WRITE_ALL )
}
lljTran.freeMemory()
true
} catch (e: LLJTranException) {
Timber.tag("Error").d(e)
return null
false
}
if (rotated) {
Timber.tag("Done rotating image").d("Done")
Timber.tag("Add").d(output.absolutePath)
}
return output
}
}

View file

@ -11,12 +11,11 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.viewpager.widget.ViewPager.OnPageChangeListener;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.databinding.FragmentExploreBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.theme.BaseActivity;
@ -33,10 +32,8 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
private static final String EXPLORE_MAP = "Map";
private static final String MEDIA_DETAILS_FRAGMENT_TAG = "MediaDetailsFragment";
@BindView(R.id.tab_layout)
TabLayout tabLayout;
@BindView(R.id.viewPager)
ParentViewPager viewPager;
public FragmentExploreBinding binding;
ViewPagerAdapter viewPagerAdapter;
private ExploreListRootFragment featuredRootFragment;
private ExploreListRootFragment mobileRootFragment;
@ -46,7 +43,10 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
public JsonKvStore applicationKvStore;
public void setScroll(boolean canScroll){
viewPager.setCanScroll(canScroll);
if (binding != null)
{
binding.viewPager.setCanScroll(canScroll);
}
}
@NonNull
@ -56,22 +56,17 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
return fragment;
}
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
View view = inflater.inflate(R.layout.fragment_explore, container, false);
ButterKnife.bind(this, view);
binding = FragmentExploreBinding.inflate(inflater, container, false);
viewPagerAdapter = new ViewPagerAdapter(getChildFragmentManager());
viewPager.setAdapter(viewPagerAdapter);
viewPager.setId(R.id.viewPager);
tabLayout.setupWithViewPager(viewPager);
viewPager.addOnPageChangeListener(new OnPageChangeListener() {
binding.viewPager.setAdapter(viewPagerAdapter);
binding.viewPager.setId(R.id.viewPager);
binding.tabLayout.setupWithViewPager(binding.viewPager);
binding.viewPager.addOnPageChangeListener(new OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset,
int positionOffsetPixels) {
@ -81,9 +76,9 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
@Override
public void onPageSelected(int position) {
if (position == 2) {
viewPager.setCanScroll(false);
binding.viewPager.setCanScroll(false);
} else {
viewPager.setCanScroll(true);
binding.viewPager.setCanScroll(true);
}
}
@ -94,7 +89,7 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
});
setTabs();
setHasOptionsMenu(true);
return view;
return binding.getRoot();
}
/**
@ -133,13 +128,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
}
public boolean onBackPressed() {
if (tabLayout.getSelectedTabPosition() == 0) {
if (binding.tabLayout.getSelectedTabPosition() == 0) {
if (featuredRootFragment.backPressed()) {
((BaseActivity) getActivity()).getSupportActionBar()
.setDisplayHomeAsUpEnabled(false);
return true;
}
} else if (tabLayout.getSelectedTabPosition() == 1) { //Mobile root fragment
} else if (binding.tabLayout.getSelectedTabPosition() == 1) { //Mobile root fragment
if (mobileRootFragment.backPressed()) {
((BaseActivity) getActivity()).getSupportActionBar()
.setDisplayHomeAsUpEnabled(false);
@ -180,6 +175,12 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
return super.onOptionsItemSelected(item);
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -9,12 +9,11 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryImagesCallback;
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.explore.categories.media.CategoriesMediaFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
@ -26,8 +25,7 @@ public class ExploreListRootFragment extends CommonsDaggerSupportFragment implem
private MediaDetailPagerFragment mediaDetails;
private CategoriesMediaFragment listFragment;
@BindView(R.id.explore_container)
FrameLayout container;
private FragmentFeaturedRootBinding binding;
public ExploreListRootFragment() {
//empty constructor necessary otherwise crashes on recreate
@ -47,9 +45,9 @@ public class ExploreListRootFragment extends CommonsDaggerSupportFragment implem
@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
@ -109,9 +107,13 @@ public class ExploreListRootFragment extends CommonsDaggerSupportFragment implem
@Override
public void onMediaClicked(int position) {
container.setVisibility(View.VISIBLE);
((ExploreFragment) getParentFragment()).tabLayout.setVisibility(View.GONE);
mediaDetails = new MediaDetailPagerFragment(false, true);
if (binding!=null) {
binding.exploreContainer.setVisibility(View.VISIBLE);
}
if (((ExploreFragment) getParentFragment()).binding!=null) {
((ExploreFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
}
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
((ExploreFragment) getParentFragment()).setScroll(false);
setFragment(mediaDetails, listFragment);
mediaDetails.showImage(position);
@ -185,16 +187,29 @@ public class ExploreListRootFragment extends CommonsDaggerSupportFragment implem
*/
public boolean backPressed() {
if (null != mediaDetails && mediaDetails.isVisible()) {
((ExploreFragment) getParentFragment()).tabLayout.setVisibility(View.VISIBLE);
if (((ExploreFragment) getParentFragment()).binding != null) {
((ExploreFragment) getParentFragment()).binding.tabLayout.setVisibility(View.VISIBLE);
}
removeFragment(mediaDetails);
((ExploreFragment) getParentFragment()).setScroll(true);
setFragment(listFragment, mediaDetails);
((MainActivity) getActivity()).showTabs();
return true;
} else {
((MainActivity) getActivity()).setSelectedItemId(NavTab.CONTRIBUTIONS.code());
if (((MainActivity) getActivity()) != null) {
((MainActivity) getActivity()).setSelectedItemId(NavTab.CONTRIBUTIONS.code());
}
}
if (((MainActivity) getActivity()) != null) {
((MainActivity) getActivity()).showTabs();
}
((MainActivity) getActivity()).showTabs();
return false;
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -9,12 +9,11 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryImagesCallback;
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.explore.map.ExploreMapFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
@ -26,8 +25,7 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme
private MediaDetailPagerFragment mediaDetails;
private ExploreMapFragment mapFragment;
@BindView(R.id.explore_container)
FrameLayout container;
private FragmentFeaturedRootBinding binding;
public ExploreMapRootFragment() {
//empty constructor necessary otherwise crashes on recreate
@ -54,9 +52,10 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme
@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
@ -116,9 +115,9 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme
@Override
public void onMediaClicked(int position) {
container.setVisibility(View.VISIBLE);
((ExploreFragment) getParentFragment()).tabLayout.setVisibility(View.GONE);
mediaDetails = new MediaDetailPagerFragment(false, true);
binding.exploreContainer.setVisibility(View.VISIBLE);
((ExploreFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
((ExploreFragment) getParentFragment()).setScroll(false);
setFragment(mediaDetails, mapFragment);
mediaDetails.showImage(position);
@ -192,7 +191,7 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme
*/
public boolean backPressed() {
if (null != mediaDetails && mediaDetails.isVisible()) {
((ExploreFragment) getParentFragment()).tabLayout.setVisibility(View.VISIBLE);
((ExploreFragment) getParentFragment()).binding.tabLayout.setVisibility(View.VISIBLE);
removeFragment(mediaDetails);
((ExploreFragment) getParentFragment()).setScroll(true);
setFragment(mapFragment, mediaDetails);
@ -213,4 +212,11 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme
((MainActivity) getActivity()).showTabs();
return false;
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -3,23 +3,17 @@ package fr.free.nrw.commons.explore;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.SearchView;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.viewpager.widget.ViewPager;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.google.android.material.tabs.TabLayout;
import com.jakewharton.rxbinding2.view.RxView;
import com.jakewharton.rxbinding2.widget.RxSearchView;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.category.CategoryImagesCallback;
import fr.free.nrw.commons.databinding.ActivitySearchBinding;
import fr.free.nrw.commons.explore.categories.search.SearchCategoryFragment;
import fr.free.nrw.commons.explore.depictions.search.SearchDepictionsFragment;
import fr.free.nrw.commons.explore.media.SearchMediaFragment;
@ -45,13 +39,6 @@ import timber.log.Timber;
public class SearchActivity extends BaseActivity
implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback {
@BindView(R.id.toolbar_search) Toolbar toolbar;
@BindView(R.id.searchHistoryContainer) FrameLayout searchHistoryContainer;
@BindView(R.id.mediaContainer) FrameLayout mediaContainer;
@BindView(R.id.searchBox) SearchView searchView;
@BindView(R.id.tab_layout) TabLayout tabLayout;
@BindView(R.id.viewPager) ViewPager viewPager;
@Inject
RecentSearchesDao recentSearchesDao;
@ -63,25 +50,28 @@ public class SearchActivity extends BaseActivity
private MediaDetailPagerFragment mediaDetails;
ViewPagerAdapter viewPagerAdapter;
private ActivitySearchBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_search);
ButterKnife.bind(this);
binding = ActivitySearchBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setTitle(getString(R.string.title_activity_search));
setSupportActionBar(toolbar);
setSupportActionBar(binding.toolbarSearch);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
toolbar.setNavigationOnClickListener(v->onBackPressed());
binding.toolbarSearch.setNavigationOnClickListener(v->onBackPressed());
supportFragmentManager = getSupportFragmentManager();
setSearchHistoryFragment();
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
viewPager.setAdapter(viewPagerAdapter);
viewPager.setOffscreenPageLimit(2); // Because we want all the fragments to be alive
tabLayout.setupWithViewPager(viewPager);
binding.viewPager.setAdapter(viewPagerAdapter);
binding.viewPager.setOffscreenPageLimit(2); // Because we want all the fragments to be alive
binding.tabLayout.setupWithViewPager(binding.viewPager);
setTabs();
searchView.setQueryHint(getString(R.string.search_commons));
searchView.onActionViewExpanded();
searchView.clearFocus();
binding.searchBox.setQueryHint(getString(R.string.search_commons));
binding.searchBox.onActionViewExpanded();
binding.searchBox.clearFocus();
}
@ -113,8 +103,8 @@ public class SearchActivity extends BaseActivity
viewPagerAdapter.setTabData(fragmentList, titleList);
viewPagerAdapter.notifyDataSetChanged();
compositeDisposable.add(RxSearchView.queryTextChanges(searchView)
.takeUntil(RxView.detaches(searchView))
compositeDisposable.add(RxSearchView.queryTextChanges(binding.searchBox)
.takeUntil(RxView.detaches(binding.searchBox))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::handleSearch, Timber::e
@ -124,9 +114,9 @@ public class SearchActivity extends BaseActivity
private void handleSearch(final CharSequence query) {
if (!TextUtils.isEmpty(query)) {
saveRecentSearch(query.toString());
viewPager.setVisibility(View.VISIBLE);
tabLayout.setVisibility(View.VISIBLE);
searchHistoryContainer.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.VISIBLE);
binding.tabLayout.setVisibility(View.VISIBLE);
binding.searchHistoryContainer.setVisibility(View.GONE);
if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) {
searchDepictionsFragment.onQueryUpdated(query.toString());
@ -144,10 +134,10 @@ public class SearchActivity extends BaseActivity
else {
//Open RecentSearchesFragment
recentSearchesFragment.updateRecentSearches();
viewPager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.GONE);
binding.tabLayout.setVisibility(View.GONE);
setSearchHistoryFragment();
searchHistoryContainer.setVisibility(View.VISIBLE);
binding.searchHistoryContainer.setVisibility(View.VISIBLE);
}
}
@ -215,13 +205,13 @@ public class SearchActivity extends BaseActivity
@Override
public void onMediaClicked(int index) {
ViewUtil.hideKeyboard(this.findViewById(R.id.searchBox));
tabLayout.setVisibility(View.GONE);
viewPager.setVisibility(View.GONE);
mediaContainer.setVisibility(View.VISIBLE);
searchView.setVisibility(View.GONE);// to remove searchview when mediaDetails fragment open
binding.tabLayout.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.GONE);
binding.mediaContainer.setVisibility(View.VISIBLE);
binding.searchBox.setVisibility(View.GONE);// to remove searchview when mediaDetails fragment open
if (mediaDetails == null || !mediaDetails.isVisible()) {
// set isFeaturedImage true for featured images, to include author field on media detail
mediaDetails = new MediaDetailPagerFragment(false, true);
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
supportFragmentManager
.beginTransaction()
.hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount()))
@ -269,12 +259,12 @@ public class SearchActivity extends BaseActivity
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
// back to search so show search toolbar and hide navigation toolbar
searchView.setVisibility(View.VISIBLE);//set the searchview
tabLayout.setVisibility(View.VISIBLE);
viewPager.setVisibility(View.VISIBLE);
mediaContainer.setVisibility(View.GONE);
binding.searchBox.setVisibility(View.VISIBLE);//set the searchview
binding.tabLayout.setVisibility(View.VISIBLE);
binding.viewPager.setVisibility(View.VISIBLE);
binding.mediaContainer.setVisibility(View.GONE);
} else {
toolbar.setVisibility(View.GONE);
binding.toolbarSearch.setVisibility(View.GONE);
}
super.onBackPressed();
}
@ -284,15 +274,16 @@ public class SearchActivity extends BaseActivity
* @param query Recent Search Query
*/
public void updateText(String query) {
searchView.setQuery(query, true);
binding.searchBox.setQuery(query, true);
// Clear focus of searchView now. searchView.clearFocus(); does not seem to work Check the below link for more details.
// https://stackoverflow.com/questions/6117967/how-to-remove-focus-without-setting-focus-to-another-control/15481511
viewPager.requestFocus();
binding.viewPager.requestFocus();
}
@Override protected void onDestroy() {
super.onDestroy();
//Dispose the disposables when the activity is destroyed
compositeDisposable.dispose();
binding = null;
}
}

View file

@ -7,11 +7,11 @@ import fr.free.nrw.commons.upload.depicts.DepictsInterface
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import fr.free.nrw.commons.upload.structure.depictions.get
import fr.free.nrw.commons.wikidata.WikidataProperties
import fr.free.nrw.commons.wikidata.model.DataValue
import fr.free.nrw.commons.wikidata.model.DepictSearchItem
import fr.free.nrw.commons.wikidata.model.Entities
import fr.free.nrw.commons.wikidata.model.Statement_partial
import io.reactivex.Single
import org.wikipedia.wikidata.DataValue
import org.wikipedia.wikidata.Entities
import org.wikipedia.wikidata.Statement_partial
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -13,8 +13,6 @@ import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.Media;
@ -23,6 +21,7 @@ import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao;
import fr.free.nrw.commons.category.CategoryImagesCallback;
import fr.free.nrw.commons.databinding.ActivityWikidataItemDetailsBinding;
import fr.free.nrw.commons.explore.depictions.child.ChildDepictionsFragment;
import fr.free.nrw.commons.explore.depictions.media.DepictedImagesFragment;
import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsFragment;
@ -57,14 +56,7 @@ public class WikidataItemDetailsActivity extends BaseActivity implements MediaDe
@Inject
DepictModel depictModel;
private String wikidataItemName;
@BindView(R.id.mediaContainer)
FrameLayout mediaContainer;
@BindView(R.id.tab_layout)
TabLayout tabLayout;
@BindView(R.id.viewPager)
ViewPager viewPager;
@BindView(R.id.toolbar)
Toolbar toolbar;
private ActivityWikidataItemDetailsBinding binding;
ViewPagerAdapter viewPagerAdapter;
private DepictedItem wikidataItem;
@ -72,19 +64,20 @@ public class WikidataItemDetailsActivity extends BaseActivity implements MediaDe
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_wikidata_item_details);
ButterKnife.bind(this);
binding = ActivityWikidataItemDetailsBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
compositeDisposable = new CompositeDisposable();
supportFragmentManager = getSupportFragmentManager();
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
viewPager.setAdapter(viewPagerAdapter);
viewPager.setOffscreenPageLimit(2);
tabLayout.setupWithViewPager(viewPager);
binding.viewPager.setAdapter(viewPagerAdapter);
binding.viewPager.setOffscreenPageLimit(2);
binding.tabLayout.setupWithViewPager(binding.viewPager);
final DepictedItem depictedItem = getIntent().getParcelableExtra(
WikidataConstants.BOOKMARKS_ITEMS);
wikidataItem = depictedItem;
setSupportActionBar(toolbar);
setSupportActionBar(binding.toolbarBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setTabs();
setPageTitle();
@ -137,7 +130,7 @@ public class WikidataItemDetailsActivity extends BaseActivity implements MediaDe
fragmentList.add(parentDepictionsFragment);
titleList.add(getResources().getString(R.string.title_for_parent_classes));
viewPagerAdapter.setTabData(fragmentList, titleList);
viewPager.setOffscreenPageLimit(2);
binding.viewPager.setOffscreenPageLimit(2);
viewPagerAdapter.notifyDataSetChanged();
}
@ -148,12 +141,12 @@ public class WikidataItemDetailsActivity extends BaseActivity implements MediaDe
*/
@Override
public void onMediaClicked(int position) {
tabLayout.setVisibility(View.GONE);
viewPager.setVisibility(View.GONE);
mediaContainer.setVisibility(View.VISIBLE);
binding.tabLayout.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.GONE);
binding.mediaContainer.setVisibility(View.VISIBLE);
if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) {
// set isFeaturedImage true for featured images, to include author field on media detail
mediaDetailPagerFragment = new MediaDetailPagerFragment(false, true);
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true);
FragmentManager supportFragmentManager = getSupportFragmentManager();
supportFragmentManager
.beginTransaction()
@ -183,9 +176,9 @@ public class WikidataItemDetailsActivity extends BaseActivity implements MediaDe
@Override
public void onBackPressed() {
if (supportFragmentManager.getBackStackEntryCount() == 1){
tabLayout.setVisibility(View.VISIBLE);
viewPager.setVisibility(View.VISIBLE);
mediaContainer.setVisibility(View.GONE);
binding.tabLayout.setVisibility(View.VISIBLE);
binding.viewPager.setVisibility(View.VISIBLE);
binding.mediaContainer.setVisibility(View.GONE);
}
super.onBackPressed();
}

View file

@ -20,11 +20,11 @@ public class ExploreMapCalls {
/**
* Calls method to query Commons for uploads around a location
*
* @param curLatLng coordinates of search location
* @param currentLatLng coordinates of search location
* @return list of places obtained
*/
List<Media> callCommonsQuery(final LatLng curLatLng) {
String coordinates = curLatLng.getLatitude() + "|" + curLatLng.getLongitude();
List<Media> callCommonsQuery(final LatLng currentLatLng) {
String coordinates = currentLatLng.getLatitude() + "|" + currentLatLng.getLongitude();
return mediaClient.getMediaListFromGeoSearch(coordinates).blockingGet();
}

View file

@ -1,45 +1,34 @@
package fr.free.nrw.commons.explore.map;
import android.content.Context;
import com.mapbox.mapboxsdk.annotations.Marker;
import com.mapbox.mapboxsdk.camera.CameraUpdate;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.NearbyBaseMarker;
import fr.free.nrw.commons.nearby.Place;
import java.util.List;
public class ExploreMapContract {
interface View {
boolean isNetworkConnectionEstablished();
void populatePlaces(LatLng curlatLng,LatLng searchLatLng);
void checkPermissionsAndPerformAction();
void populatePlaces(LatLng curlatLng);
void askForLocationPermission();
void recenterMap(LatLng curLatLng);
void showLocationOffDialog();
void openLocationSettings();
void hideBottomDetailsSheet();
void displayBottomSheetWithInfo(Marker marker);
void addOnCameraMoveListener();
LatLng getMapCenter();
LatLng getMapFocus();
LatLng getLastMapFocus();
void addMarkersToMap(final List<BaseMarker> nearbyBaseMarkers);
void clearAllMarkers();
void addSearchThisAreaButtonAction();
void setSearchThisAreaButtonVisibility(boolean isVisible);
void setProgressBarVisibility(boolean isVisible);
boolean isDetailsBottomSheetVisible();
boolean isSearchThisAreaButtonVisible();
void addCurrentLocationMarker(LatLng curLatLng);
void updateMapToTrackPosition(LatLng curLatLng);
Context getContext();
LatLng getCameraTarget();
void centerMapToPlace(Place placeToCenter);
LatLng getLastLocation();
com.mapbox.mapboxsdk.geometry.LatLng getLastFocusLocation();
boolean isCurrentLocationMarkerVisible();
void setProjectorLatLngBounds();
void disableFABRecenter();
void enableFABRecenter();
void addNearbyMarkersToMapBoxMap(final List<NearbyBaseMarker> nearbyBaseMarkers, final Marker selectedMarker);
void setMapBoundaries(CameraUpdate cameaUpdate);
void setFABRecenterAction(android.view.View.OnClickListener onClickListener);
boolean backButtonClicked();
}
@ -51,9 +40,6 @@ public class ExploreMapContract {
void detachView();
void setActionListeners(JsonKvStore applicationKvStore);
boolean backButtonClicked();
void onCameraMove(com.mapbox.mapboxsdk.geometry.LatLng latLng);
void markerUnselected();
void markerSelected(Marker marker);
}
}

View file

@ -14,13 +14,11 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.mapbox.mapboxsdk.annotations.IconFactory;
import com.mapbox.mapboxsdk.annotations.Marker;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.MapController;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.NearbyBaseMarker;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.utils.ImageUtils;
import fr.free.nrw.commons.utils.LocationUtils;
@ -33,6 +31,7 @@ import javax.inject.Inject;
import timber.log.Timber;
public class ExploreMapController extends MapController {
private final ExploreMapCalls exploreMapCalls;
public LatLng latestSearchLocation; // Can be current and camera target on search this area button is used
public LatLng currentLocation; // current location of user
@ -46,13 +45,18 @@ public class ExploreMapController extends MapController {
}
/**
* Takes location as parameter and returns ExplorePlaces info that holds curLatLng, mediaList, explorePlaceList and boundaryCoordinates
* @param curLatLng is current geolocation
* @param searchLatLng is the location that we want to search around
* @param checkingAroundCurrentLocation is a boolean flag. True if we want to check around current location, false if another location
* @return explorePlacesInfo info that holds curLatLng, mediaList, explorePlaceList and boundaryCoordinates
* Takes location as parameter and returns ExplorePlaces info that holds currentLatLng, mediaList,
* explorePlaceList and boundaryCoordinates
*
* @param currentLatLng is current geolocation
* @param searchLatLng is the location that we want to search around
* @param checkingAroundCurrentLocation is a boolean flag. True if we want to check around
* current location, false if another location
* @return explorePlacesInfo info that holds currentLatLng, mediaList, explorePlaceList and
* boundaryCoordinates
*/
public ExplorePlacesInfo loadAttractionsFromLocation(LatLng curLatLng, LatLng searchLatLng, boolean checkingAroundCurrentLocation) {
public ExplorePlacesInfo loadAttractionsFromLocation(LatLng currentLatLng, LatLng searchLatLng,
boolean checkingAroundCurrentLocation) {
if (searchLatLng == null) {
Timber.d("Loading attractions explore map, but search is null");
@ -61,7 +65,7 @@ public class ExploreMapController extends MapController {
ExplorePlacesInfo explorePlacesInfo = new ExplorePlacesInfo();
try {
explorePlacesInfo.curLatLng = curLatLng;
explorePlacesInfo.currentLatLng = currentLatLng;
latestSearchLocation = searchLatLng;
List<Media> mediaList = exploreMapCalls.callCommonsQuery(searchLatLng);
@ -74,18 +78,23 @@ public class ExploreMapController extends MapController {
Timber.d("Sorting places by distance...");
final Map<Media, Double> distances = new HashMap<>();
for (Media media : mediaList) {
distances.put(media, computeDistanceBetween(media.getCoordinates(), searchLatLng));
distances.put(media,
computeDistanceBetween(media.getCoordinates(), searchLatLng));
// Find boundaries with basic find max approach
if (media.getCoordinates().getLatitude() < boundaryCoordinates[0].getLatitude()) {
if (media.getCoordinates().getLatitude()
< boundaryCoordinates[0].getLatitude()) {
boundaryCoordinates[0] = media.getCoordinates();
}
if (media.getCoordinates().getLatitude() > boundaryCoordinates[1].getLatitude()) {
if (media.getCoordinates().getLatitude()
> boundaryCoordinates[1].getLatitude()) {
boundaryCoordinates[1] = media.getCoordinates();
}
if (media.getCoordinates().getLongitude() < boundaryCoordinates[2].getLongitude()) {
if (media.getCoordinates().getLongitude()
< boundaryCoordinates[2].getLongitude()) {
boundaryCoordinates[2] = media.getCoordinates();
}
if (media.getCoordinates().getLongitude() > boundaryCoordinates[3].getLongitude()) {
if (media.getCoordinates().getLongitude()
> boundaryCoordinates[3].getLongitude()) {
boundaryCoordinates[3] = media.getCoordinates();
}
}
@ -96,7 +105,8 @@ public class ExploreMapController extends MapController {
// Sets latestSearchRadius to maximum distance among boundaries and search location
for (LatLng bound : boundaryCoordinates) {
double distance = LocationUtils.commonsLatLngToMapBoxLatLng(bound).distanceTo(LocationUtils.commonsLatLngToMapBoxLatLng(latestSearchLocation));
double distance = LocationUtils.calculateDistance(bound.getLatitude(),
bound.getLongitude(), searchLatLng.getLatitude(), searchLatLng.getLongitude());
if (distance > latestSearchRadius) {
latestSearchRadius = distance;
}
@ -105,7 +115,7 @@ public class ExploreMapController extends MapController {
// Our radius searched around us, will be used to understand when user search their own location, we will follow them
if (checkingAroundCurrentLocation) {
currentLocationSearchRadius = latestSearchRadius;
currentLocation = curLatLng;
currentLocation = currentLatLng;
}
} catch (Exception e) {
e.printStackTrace();
@ -115,42 +125,42 @@ public class ExploreMapController extends MapController {
/**
* Loads attractions from location for map view, we need to return places in Place data type
*
* @return baseMarkerOptions list that holds nearby places with their icons
*/
public static List<NearbyBaseMarker> loadAttractionsFromLocationToBaseMarkerOptions(
LatLng curLatLng,
public static List<BaseMarker> loadAttractionsFromLocationToBaseMarkerOptions(
LatLng currentLatLng,
final List<Place> placeList,
Context context,
NearbyBaseMarkerThumbCallback callback,
Marker selectedMarker,
boolean shouldTrackPosition,
ExplorePlacesInfo explorePlacesInfo) {
List<NearbyBaseMarker> baseMarkerOptions = new ArrayList<>();
List<BaseMarker> baseMarkerList = new ArrayList<>();
if (placeList == null) {
return baseMarkerOptions;
return baseMarkerList;
}
VectorDrawableCompat vectorDrawable = null;
try {
vectorDrawable = VectorDrawableCompat.create(
context.getResources(), R.drawable.ic_custom_map_marker, context.getTheme());
context.getResources(), R.drawable.ic_custom_map_marker_dark, context.getTheme());
} catch (Resources.NotFoundException e) {
// ignore when running tests.
}
if (vectorDrawable != null) {
for (Place explorePlace : placeList) {
final NearbyBaseMarker nearbyBaseMarker = new NearbyBaseMarker();
String distance = formatDistanceBetween(curLatLng, explorePlace.location);
final BaseMarker baseMarker = new BaseMarker();
String distance = formatDistanceBetween(currentLatLng, explorePlace.location);
explorePlace.setDistance(distance);
nearbyBaseMarker.title(explorePlace.name.substring(5, explorePlace.name.lastIndexOf(".")));
nearbyBaseMarker.position(
new com.mapbox.mapboxsdk.geometry.LatLng(
baseMarker.setTitle(
explorePlace.name.substring(5, explorePlace.name.lastIndexOf(".")));
baseMarker.setPosition(
new fr.free.nrw.commons.location.LatLng(
explorePlace.location.getLatitude(),
explorePlace.location.getLongitude()));
nearbyBaseMarker.place(explorePlace);
explorePlace.location.getLongitude(), 0));
baseMarker.setPlace(explorePlace);
Glide.with(context)
.asBitmap()
@ -160,12 +170,15 @@ public class ExploreMapController extends MapController {
.into(new CustomTarget<Bitmap>() {
// We add icons to markers when bitmaps are ready
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
nearbyBaseMarker.setIcon(IconFactory.getInstance(context).fromBitmap(
ImageUtils.addRedBorder(resource, 6, context)));
baseMarkerOptions.add(nearbyBaseMarker);
if (baseMarkerOptions.size() == placeList.size()) { // if true, we added all markers to list and can trigger thumbs ready callback
callback.onNearbyBaseMarkerThumbsReady(baseMarkerOptions, explorePlacesInfo, selectedMarker, shouldTrackPosition);
public void onResourceReady(@NonNull Bitmap resource,
@Nullable Transition<? super Bitmap> transition) {
baseMarker.setIcon(
ImageUtils.addRedBorder(resource, 6, context));
baseMarkerList.add(baseMarker);
if (baseMarkerList.size()
== placeList.size()) { // if true, we added all markers to list and can trigger thumbs ready callback
callback.onNearbyBaseMarkerThumbsReady(baseMarkerList,
explorePlacesInfo);
}
}
@ -177,20 +190,24 @@ public class ExploreMapController extends MapController {
@Override
public void onLoadFailed(@Nullable final Drawable errorDrawable) {
super.onLoadFailed(errorDrawable);
nearbyBaseMarker.setIcon(IconFactory.getInstance(context).fromResource(R.drawable.image_placeholder_96));
baseMarkerOptions.add(nearbyBaseMarker);
if (baseMarkerOptions.size() == placeList.size()) { // if true, we added all markers to list and can trigger thumbs ready callback
callback.onNearbyBaseMarkerThumbsReady(baseMarkerOptions, explorePlacesInfo, selectedMarker, shouldTrackPosition);
baseMarker.fromResource(context, R.drawable.image_placeholder_96);
baseMarkerList.add(baseMarker);
if (baseMarkerList.size()
== placeList.size()) { // if true, we added all markers to list and can trigger thumbs ready callback
callback.onNearbyBaseMarkerThumbsReady(baseMarkerList,
explorePlacesInfo);
}
}
});
}
}
return baseMarkerOptions;
return baseMarkerList;
}
interface NearbyBaseMarkerThumbCallback {
// Callback to notify thumbnails of explore markers are added as icons and ready
void onNearbyBaseMarkerThumbsReady(List<NearbyBaseMarker> baseMarkers, ExplorePlacesInfo explorePlacesInfo, Marker selectedMarker, boolean shouldTrackPosition);
void onNearbyBaseMarkerThumbsReady(List<BaseMarker> baseMarkers,
ExplorePlacesInfo explorePlacesInfo);
}
}

Some files were not shown because too many files have changed in this diff Show more