diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..dcbba0597 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,85 @@ +name: "\U0001F41E Bug report" +description: Create a report to help us improve. +title: "[Bug]: " +type: Bug # Retained to categorize the issue as per organization-level type +body: + - type: markdown + attributes: + value: | + - Before creating an issue, please search the existing issues to see if a similar one has already been created. + - You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter. + - type: textarea + attributes: + label: Summary + description: Summarize your issue (what goes wrong, what did you expect to happen) + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: How can we reproduce the issue? + placeholder: | + 1. Have the app open.. + 2. Go to.. + 3. Click on.. + 4. Observe.. + validations: + required: true + - type: textarea + attributes: + label: Expected behaviour + placeholder: A menu should open.. + validations: + required: true + - type: textarea + attributes: + label: Actual behaviour + placeholder: The app closes unexpectedly.. + validations: + required: true + - type: markdown + attributes: + value: | + # Device information + - type: input + attributes: + label: Device name + description: What make and model device did you encounter this on? + placeholder: Samsung J7 + validations: + required: false + - type: input + attributes: + label: Android version + description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ? + placeholder: Android 10 + validations: + required: true + - type: input + attributes: + label: Commons app version + description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`). + placeholder: 3.1.1 + validations: + required: true + - type: textarea + attributes: + label: Device logs + description: Add logcat files here (if possible). Need help? See "[Getting app logs from Android Studio](https://commons-app.github.io/docs.html#getting-app-logs-from-android-studio)". + validations: + required: false + - type: textarea + attributes: + label: Screenshots + description: Add screenshots related to the issue (if available). Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher. + validations: + required: false + - type: dropdown + attributes: + label: Would you like to work on the issue? + description: Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them. + options: + - "Yes" + - Prefer not + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..5ac210240 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,30 @@ +name: "⭐️ Feature request" +description: Suggest an idea for this project +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + - Please do your best to search for duplicate issues before filing a new issue so we can keep our issue board clean. + - Every issue should have exactly one feature request described in it. Please do not file feedback list tickets as it is difficult to parse them and address their individual points. + - Feature Requests are better when they’re open-ended instead of demanding a specific solution e.g: “I want an easier way to do X” instead of “add Y”. + - type: textarea + attributes: + label: What is the user problem or growth opportunity you want to see solved? + validations: + required: false + - type: textarea + attributes: + label: How do you know that this problem exists today? Why is this important? + validations: + required: false + - type: textarea + attributes: + label: Who will benefit from it? + validations: + required: false + - type: textarea + attributes: + label: Anything else you would like to add? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml new file mode 100644 index 000000000..febde65f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -0,0 +1,46 @@ +name: "\U0001F4AC Feedback" +description: Share your feedback about the app +labels: ["feedback"] +body: + - type: markdown + attributes: + value: | + - Before creating an issue, please search the existing issues to see if a similar one has already been created. + - You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter. + - type: textarea + attributes: + label: Feedback + description: Share your feedback about the app. + validations: + required: true + - type: input + attributes: + label: Wiki username + placeholder: Jimbo Wales + validations: + required: false + - type: markdown + attributes: + value: | + # Device information + - type: input + attributes: + label: Device name + description: What make and model device did you encounter this on? + placeholder: Samsung J7 + validations: + required: false + - type: input + attributes: + label: Android version + description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ? + placeholder: Android 10 + validations: + required: false + - type: input + attributes: + label: Commons app version + description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`). + placeholder: 3.1.1 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/need-help.yml b/.github/ISSUE_TEMPLATE/need-help.yml new file mode 100644 index 000000000..64ddabda6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/need-help.yml @@ -0,0 +1,13 @@ +name: "✋🏻 Need help" +description: Describe the situation which you need help with. +labels: ["help needed"] +body: + - type: markdown + attributes: + value: | + - Describe the situation which you need help with with as much information as possible. + - type: textarea + attributes: + label: Description + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c6d2abdd9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +**Description (required)** + +Fixes #INSERT_ISSUE_NUMBER_HERE + +What changes did you make and why? + +**Tests performed (required)** + +Tested {build variant, e.g. ProdDebug} on {name of device or emulator} with API level {API level}. + +**Screenshots (for UI changes only)** + +Need help? See https://support.google.com/android/answer/9075928 + +--- + +_Note: Please ensure that you have read CONTRIBUTING.md if this is your first pull request._ diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..bc8b03c9e --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,114 @@ +name: Android CI + +on: [push, pull_request, workflow_dispatch] + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Run tests and generate APK + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Cache packages + id: cache-packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-packages-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }} + restore-keys: gradle-packages-${{ runner.os }} + + - name: Access test login credentials + run: | + echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties + echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties + + - name: AVD cache + if: github.event_name != 'pull_request' + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-tablet-api-24 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 24 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: echo "Generated AVD snapshot for caching." + + - name: Run Instrumentation tests + if: github.event_name != 'pull_request' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 24 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + profile: Nexus 10 + script: | + adb shell content insert --uri content://settings/system --bind name:s:accelerometer_rotation --bind value:i:0 + adb shell content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:0 + adb emu geo fix 37.422131 -122.084801 + ./gradlew connectedBetaDebugAndroidTest --stacktrace + + - name: Run Unit tests with unified coverage + if: github.event_name != 'pull_request' + run: ./gradlew -Pcoverage testBetaDebugUnitTestUnifiedCoverage --stacktrace + + - name: Run Unit tests without unified coverage + if: github.event_name == 'pull_request' + run: ./gradlew -Pcoverage testBetaDebugUnitTestCoverage --stacktrace + + - name: Upload Test Report to Codecov + if: github.event_name != 'pull_request' + run: | + curl -Os https://uploader.codecov.io/latest/linux/codecov + chmod +x codecov + ./codecov -f "app/build/reports/jacoco/testBetaDebugUnitTestUnifiedCoverage/testBetaDebugUnitTestUnifiedCoverage.xml" -Z + + - name: Generate betaDebug APK + run: bash ./gradlew assembleBetaDebug --stacktrace + + - name: Upload betaDebug APK + uses: actions/upload-artifact@v4 + with: + name: betaDebugAPK + path: app/build/outputs/apk/beta/debug/app-*.apk + + - name: Generate prodDebug APK + run: bash ./gradlew assembleProdDebug --stacktrace + + - name: Upload prodDebug APK + uses: actions/upload-artifact@v4 + with: + name: prodDebugAPK + path: app/build/outputs/apk/prod/debug/app-*.apk + + - name: Create and PR number artifact + run: | + echo "{\"pr_number\": ${{ github.event.pull_request.number || 'null' }}}" > pr_number.json + + - name: Upload PR number artifact + uses: actions/upload-artifact@v4 + with: + name: pr_number + path: ./pr_number.json diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 000000000..8e1a26e15 --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -0,0 +1,41 @@ +name: Build beta only + +on: [workflow_dispatch] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Access test login credentials + run: | + echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties + echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Set env + run: echo "COMMIT_SHA=$(git log -n 1 --format='%h')" >> $GITHUB_ENV + + - name: Generate betaDebug APK + run: ./gradlew assembleBetaDebug --stacktrace + + - name: Rename betaDebug APK + run: mv app/build/outputs/apk/beta/debug/app-*.apk app/build/outputs/apk/beta/debug/apps-android-commons-betaDebug-$COMMIT_SHA.apk + + - name: Upload betaDebug APK + uses: actions/upload-artifact@v4 + with: + name: apps-android-commons-betaDebugAPK-${{ env.COMMIT_SHA }} + path: app/build/outputs/apk/beta/debug/*.apk + retention-days: 30 diff --git a/.github/workflows/comment_artifacts_on_PR.yml b/.github/workflows/comment_artifacts_on_PR.yml new file mode 100644 index 000000000..ee4ae7c46 --- /dev/null +++ b/.github/workflows/comment_artifacts_on_PR.yml @@ -0,0 +1,96 @@ +name: Comment Artifacts on PR + +on: + workflow_run: + workflows: [ "Android CI" ] + types: [ completed ] + +permissions: + pull-requests: write + contents: read + +concurrency: + group: comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + comment: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} + steps: + - name: Download and process artifacts + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const runId = context.payload.workflow_run.id; + + const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + + const prNumberArtifact = allArtifacts.data.artifacts.find(artifact => artifact.name === "pr_number"); + if (!prNumberArtifact) { + console.log("pr_number artifact not found."); + return; + } + + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: prNumberArtifact.id, + archive_format: 'zip', + }); + + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/pr_number.zip`, Buffer.from(download.data)); + const { execSync } = require('child_process'); + execSync('unzip -q pr_number.zip -d ./pr_number/'); + fs.unlinkSync('pr_number.zip'); + + const prData = JSON.parse(fs.readFileSync('./pr_number/pr_number.json', 'utf8')); + const prNumber = prData.pr_number; + + if (!prNumber || prNumber === 'null') { + console.log("No valid PR number found in pr_number.json. Skipping."); + return; + } + + const artifactsToLink = allArtifacts.data.artifacts.filter(artifact => artifact.name !== "pr_number"); + if (artifactsToLink.length === 0) { + console.log("No artifacts to link found."); + return; + } + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + }); + + const oldComments = comments.data.filter(comment => + comment.body.startsWith("✅ Generated APK variants!") + ); + for (const comment of oldComments) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + console.log(`Deleted old comment ID: ${comment.id}`); + }; + + const commentBody = `✅ Generated APK variants!\n` + + artifactsToLink.map(artifact => { + const artifactUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/artifacts/${artifact.id}`; + return `- 🤖 [Download ${artifact.name}](${artifactUrl})`; + }).join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + body: commentBody + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..7fa4767a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# IDEA files +*.iml +*.ipr +*.iws +/.idea/* +app/src/main/gen/* + +# IDEA/Android Studio Ignore exceptions +!/.idea/codeStyles/ +!/.idea/inspectionProfiles/ + +# Gradle +.gradle +local.properties +/.gradle +/build +/app/build + +# IDE / Text editor files +.nb-gradle +.nb-gradle-properties +*.sw[op] +*~ +*.sublime-* +.textmate +.java-version + +# OS files +*.DS_Store +Thumbs.db +app/gradle/wrapper/gradle-wrapper.jar +app/gradlew +app/gradlew.bat +app/gradle/wrapper/gradle-wrapper.properties + +# related to release notes +app/src/prodRelease/play/release-notes/* + +#related to OpenCV +/libraries/opencv/build +app/src/main/jniLibs +#Below removes all the HTML files related to OpenCV documentation. The documentation can be otherwise found at: +#https://docs.opencv.org/3.3.0/ +/libraries/opencv/javadoc/ +captures/* + +# Test and other output +app/jacoco.exec +app/CommonsContributions +app/.* diff --git a/.gitreview b/.gitreview deleted file mode 100644 index 2dd0b4bb2..000000000 --- a/.gitreview +++ /dev/null @@ -1,6 +0,0 @@ -[gerrit] -host=gerrit.wikimedia.org -port=29418 -project=apps/android/commons.git -defaultbranch=master -defaultrebase=0 diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..ea0cb3b07 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + + + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..265d8a96d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,90 @@ + + + + \ No newline at end of file diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..b140127f9 --- /dev/null +++ b/.mailmap @@ -0,0 +1,5 @@ +# See: https://git-scm.com/docs/git-shortlog#_mapping_authors +# +Brooke Vibber +Brooke Vibber +Brooke Vibber diff --git a/CHANGELOG.md b/CHANGELOG.md index 074b74bc2..575aa6a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,769 @@ # Wikimedia Commons for Android +## v6.0.2 + +### What's changed +* Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard +* Links in the "File usages" list are now clickable and will take you to the correct page. +* Titles for file usages are now clearer and easier to understand +* Bug fixes and stability improvements + +## v6.0.1 + +### What's changed +* The app now supports Android 15 with an improved user interface +* Enhanced Nearby with robust and more reliable labels +* Bug fixes and stability improvements + +## v5.6.1 + +### What's changed +* The app no longer uploads images to Wikidata if one exists already for a given item +* File usage displays correctly now +* No more infinite circular progress bar on nominating an image for deletion +* Enhanced location updates while using GPS +* Author/uploader names are now available in Media Details for Commons licensing compliance +* Improved usage of popups in Nearby +* Bug fixes and stability improvements + +## v5.5.0 + +### What's changed +* Explore images will now be shown based on the map location and not at your current location +* Enhanced Wikidata feedback message +* Green labels in Explore map will no longer be hidden by other pins thumbnails +* Upload wizard's language drop-down now reflects the language used in the pin label +* Users can now pick only one image at a time while using the custom selector +* Bug fixes and stability improvements + +## v5.4.1 + +### What's changed +* Custom picker now detects images that are already available on Commons +* Improve credit line in image list +* Show place cards with loaded names only in the Nearby list +* Fix the error that occurs while loading images in Explore + +## v5.3.0 + +### What's changed +* Enable EmailAuth support +* Explore map images no longer show "Unknown" +* Fix crash when removing last two images of multiupload +* Mark ❌ for closed locations (P3999) in Nearby +* Fix two pin labels staying visible at the same time in Explore map +* Refactoring and minor UI improvements + +## v5.2.0 + +v5.2.0 boasts several new functionalities like: + +* A new refresh button lets you quickly reload the Nearby map +* Bookmarks now support categories +* Improved feedback and consistency in the user interface +* Bug fixes and performance improvements + +### What's changed +* Implement "Refresh" button to clear the cache and reload the Nearby map. +* `CommonsApplication` migrate to kotlin & some lint fixes. +* Revert back to MainScope for database and UI updates and make database operations thread safe. +* Hide edit options for logged-out users in Explore screen. +* Introduced a button to delete the current folder in custom selector. +* Improve Unique File Name Search. +* Migration of several modules from Java to Kotlin. +* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins. +* Bug fixes and enhancement of Achievements screen. +* Show where file is being used on Commons and other wikis. +* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices. +* Make dialogs modal and always show the upload icon. +* Fix unintentional deletion of subfolders and non-images by custom selector. +* Bookmark categories. +* Add pull down to refresh in the Contributions screen. +* Fix race condition and lag when loading pin details, faster overlay management. +* Show cached pins in Nearby even when internet is unavailable + + Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0). + + +## v5.1.2 + +### What's changed + +* Fix the broken category search in the explore screen + +## v5.1.1 + +### What's changed + +* Use Android's new EXIF interface to mitigate security issues in old + EXIF interface. +* Make the icon that helps view the upload queue always visible as it ensures + that the queue accessible at all times. + +## v5.1.0 + +### What's Changed + +* Enhanced **upload queue management** in the Commons app for smoother, sequential + processing, clearer progress tracking, prevention of stuck or duplicate + uploads. As part of this improvement, the "Limited Connection mode" has been + removed. +* Added an option in "Nearby" feature enabling users to **provide feedback on + Wikidata items**. Users can report if an item doesn’t exist, is at a different + location, or has other issues, with submissions tagged for easy tracking and + updates. +* Improved the "Nearby" feature by splitting the query into two parts for faster + loading and **better performance, especially in areas with dense amount of + places**. This update also resolves issues with pins overlapping place names. +* Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to + the app such as adding **"Partial Access" support**. Also includes some minor + refactoring, and replacement of deprecated circular progress bars. +* Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs + appeared blank** in the Category Details screen. Resolved by optimizing view + binding handling in the parent fragments. +* Fixed an issue where editing depictions removed all other structured data from + images. Now, **only depictions are updated, preserving other associated data**. +* Fixed **map centering** in the image upload flow to **use GPS EXIF tag location** + from pictures and ensured "Show in map app" accurately reflects this location. +* Fixed navigation **after uploading via Nearby by directing users to the Uploads + activity** instead of returning to Nearby, preventing confusion about needing to + upload again. + +### Bug fixes and various changes + +* Improved the "Nearby" feature to fetch labels based on the user's preferred + language instead of defaulting to English. +* Added a legend to the "Nearby" feature indicating pin statuses: red for items + without pictures, green for those with pictures, and grey for items being + checked. A floating action button now allows users to toggle the legend's + visibility. +* Fixed an issue where the "Nominate for deletion" option is shown to logged out + users, preventing app errors and crashes. +* Updated the regex pattern that filters categories with an year in it to also + filter the 2020s. +* Fix an issue where past depictions were not shown as suggestions, despite + being saved correctly. +* Fixed an issue in custom image picker where exiting the media preview showed + only the first image and cleared selections. Now, previously selected images + are restored correctly after exiting the preview. This was contributed. +* Fixed an issue in custom image picker where scrolling behavior did not + maintain position after exiting fullscreen preview, ensuring users remain at + the same point in their image roll unless actioned images are filtered. This + was contributed. +* Fixed Nearby map not showing new pins on map move by removing the 2000m scroll + threshold and adding an 800ms debounce for smoother pin updates when the map + is moved. Queued searches are now canceled on fragment destruction. +* Revised author information retrieval to emphasize the custom author name from + the metadata instead of the default registered username. +* Enhanced notification classification to properly identify "email" type + notifications and prompting users to check their e-mail inbox when such + notifications are clicked. +* Resolved a bug in the language chooser that incorrectly greyed-out previously + selected languages, ensuring only the current language is non-selectable during + image upload. +* Resolved pin color update issue in "Nearby" feature where the pin colour + failed to be updated after a successful image upload. + +What's listed here is only a subset of all the changes. Check the full-list of +the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0). +Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0) +for an exhaustive list of changes and the various contributors who contributed the same. + +## v5.0.2 + +- Enhanced multi-upload functionality with user prompts to clarify that all images would share the + same category and depictions. +- Show Wikidata description on currently active Nearby pin to provide more useful information. +- Improve the visibility of map markers by dynamically adjusting their colors based on the app's + theme. The map markers will now appear lighter when the app is in dark mode and darker when the + app is in light mode. This change aims to enhance marker visibility and improve the overall user + experience. +- Added information on where user feedback is posted, helping users track existing feedback and + monitor their own submissions. +- Enhanced the edit location screen of the upload screen by centering the map on the picture's + location from metadata when editing, or on the device's GPS location if metadata is unavailable, + improving accuracy and user experience. +- Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a + recently uploaded image, enhancing clarity and user experience. +- Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user + experience by showing loading progress until the image is fully loaded. +- Fixed an issue where caption and description fields would intermittently disappear when using + voice input, ensuring text remains visible and stable across all entries. +- Fixed a crash that occurred when attempting to remove multiple instances of caption/description + fields after initially adding them. +- Improve the text in the prompt shown when skipping login to sound more natural. +- Modified feedback addition logic to append new sections at the bottom of the page, ensuring + auto-archiving of sections functions correctly on the feedback page. +- Resolved issue where the app failed to clear cookies upon logout. + +## v5.0.1 + +Same as v5.0.0 except this fixes some R8 rules to ensure that the release +variants of the app work as intended. + +## v5.0.0 + +### What's Changed + +- Redesigned the map feature to **replace Mapbox with the osmdroid library**. + Key elements like pin visualization and user-centered display are still + included in this redesign. This is done to guard against possible misuse of + the Mapbox token and, more crucially, to keep the app from becoming dependent + on a service that charges for usage but offers a free tier. + + With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org). +- Add the ability to **export locations of nearby missing pictures in GPX and + KML formats**. This allows users to browse the locations with desired radius + for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing + accessibility and offline functionality. +- **Limited the uploads via the custom image picker** to a maximum of 20. +- Added two menu choices for **transparent image backgrounds**, giving users the + option of either a black or white background, increasing adaptability to + various theme settings. + + User customization option has been provided with the + ability to save background color selections permanently on a per image basis. +- Implemented functionality to **automatically resume uploads** that become + stuck due to app termination or device reboot. +- Added a **compass arrow in the Nearby banner** shown in the "Contributions" + screen to guide users towards the nearest item, thus providing the missing + directional cues. The arrow dynamically adjusts based on device rotation, + aligning with the calculated bearing towards the target location. Further, + the distance and direction are updated as the user moves. +- Implemented **voice input feature** for caption and description fields, + enabling users to dictate text directly into these fields. +- Improved various flows in the app to **redirect users to the login page** and + display a persistent message **if their session becomes invalid** due to a + password change, enhancing user guidance and security measures. + +### Revamps and refactorings + +- **Revamped initial upload screen layout and the description edit screen layout** + for enhanced user experience and ensuring better symmetry in the design. +- **Replaced Butterknife with ViewBinding** in various places of the app. +- Transferred essential code from **the redundant data-client module** to the + main Commons app code, enabling its integration and facilitating the removal + of the redundant module. Further, convert various parts of the code to Kotlin. +- **Revamped the various location permission flows** to ensure consistency for + the sake of a better user experience. + +### Bug fixes and various changes + +- Resolved an issue where paused uploads that were subsequently cancelled were + still being uploaded. +- Fixed an issue where some user information such as upload count were not + displayed in the "Contributions" and "Profile" screens. +- Fixed the long-standing broken *"Picture of the Day" widget* to restore its + usability. +- Resolved an issue where some categories were hidden at the top of Upload + Wizard suggestions. +- Resolved an issue where there was a grey empty screen at Upload wizard when + the app was denied the files permission. +- Implemented logic to bypass media in Peer Review if the current reviewer is + also the user who uploaded the media. +- Corrected arrow image behaviour in the first upload screen: now displays down + arrow when details card is fully visible, aligning with expected user + interaction. +- Updated app icon to improve visibility and recognition on F-Droid. +- Fixed issue causing all pictures to disappear and activity to reload fully in + the custom image selector after marking a picture as 'not for upload', now + ensuring only the selected picture is removed as expected. + +What's listed here is only a subset of all the changes. Check the full-list of +the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0). +Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0) +for an exhaustive list of changes and the various contributors who contributed the same. + +## v4.2.1 + +- Provide the ability to edit an image to losslessly rotate it while uploading +- Fix a bug in v4.2.0 where the nearby places were not loading +- Fix a bug where editing depictions was showing a progress bar indefinitely +- In the upload screen, use different map icons to indicate if image is being uploaded with location + metadata +- For nearby uploads, it is no longer possible to deselect the item's category and depiction +- The Mapbox account key used by the app has been changed +- Category search now shows exact matches without any discrepancies +- Various bug and crash fixes + +## v4.2.0 +- Dark mode colour improvements +- Enhancements done to address location metadata loss including the metadata loss that occurs in + latest Android versions +- Enhancements done to address the issue where uploads get stuck in queued state +- Fix the inability to upload via the in-app camera option +- Provide the ability to optionally include location metadata for in-app camera uploads in case the + device camera app does not provide location metadata +- Use geo location URL that works consistently across all map applications +- Fix crash when clicking on location target icon while trying to edit the location of an upload +- Fix crash that occurs randomly while returning to the app after leaving it in the background +- Fix crash in Sign up activity on Android version 5.0 and 5.1 +- Android 13 compatibility changes + +## v4.1.0 +- Location of pictures uploaded via custom picture selector are now recognized +- Improvements to the custom picture selector +- Ensure the WLM pictures are associated with the correct templates for each year +- Only show pictures uploaded via app in peer review +- Improve the variety of images show in peer review +- Allow going to current location in location edit dialog while uploading a picture +- Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox +- Fixed various bugs + +## v4.0.5 +- Bumped min SDK to 29 to try and solve Google policy issue +- Reverted dialog +- Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published) + +## v4.0.4 +- Added dialog for Google's location policy + +## v4.0.3 +- Added "Report" button for Google UGC policy + +## v4.0.2 +- Fixed bug with wrong dates taken from EXIF +- Fixed various crashes + +## v4.0.1 +- Fixed bug with no browser found +- Updated Mapbox SDK to fix hamburger crash + +## v4.0.0 +- Added map showing nearby Commons pictures +- Added custom SPARQL queries +- Added user profiles +- Added custom picture selector +- Various bugfixes +- Updated target SDK to 30 + +## v3.1.1 +- Optimized Nearby query +- Added Sweden's property for WLM 2021 +- Added link to wiki explaining how to contribute to WLM through app +- Fixed various bugs and crashes + +## v3.1.0 +- Added Wiki Loves Monuments integration for WLM 2021 + +## v3.0.2 +- Fixed crash when uploading high res image +- Fixed crash when viewing images in Explore + +## v3.0.1 +- Pre-fill desc in Nearby uploads with Wikidata item's label + description +- Improved ACRA crash reporting +- Fixed various crashes + +## v3.0.0 +- Added Structured Data to upload workflow, users can now add depicts +- Added Leaderboard in Achievements screen +- Added to-do system for images with no categories/descriptions or with associated Wikipedia articles that have no pictures +- Users can now modify and add categories to their uploads from the media details view +- New UI for main screen +- Limited connection mode added, users can now pause and resume uploads + +## v2.13.1 +- Added OpenStreetMap attribution +- Fixed various crashes +- Fixed SQLite error in Nearby map +- Fixed issue with Nearby uploads not being associated with Wikidata p18 + +## v2.13.0 +- New media details UI, ability to zoom and pan around image +- Added suggestions for a place that needs photos if user uploads a photo that is near one of them +- Modifications and fixes to Nearby filters based on user feedback +- Multiple crash and bug fixes + +## v2.12.3 +- Fixed issue with EXIF data, including coords, being removed from uploads + +## v2.12.2 +- Fixed crash on startup + +## v2.12.1 +- Fixed issue with Nearby loading in wrong location +- Various crash fixes + +## v2.12.0 +- Completed codebase overhaul +- Added filters for place type and place state to Nearby +- Switched to using new data client library, aimed at fixing failed uploads +- Fixed 2FA not working +- Fixed issues with upload date and deletion notifications + +## v2.11.0 +- Refactored upload process, explore/media details, and peer review to use MVP architecture +- Refactored all AsyncTasks to use RxAndroid +- Partial migration to Retrofit +- Allow users to remove EXIF tags from their uploads if desired +- Multiple crash and bug fixes + +## v2.10.2 +- Fixed remaining issues with date image taken +- Fixed database crash + +## v2.10.1 +- Fixed "stuck before category selection screen" bug +- Fixed notification taps +- Fixed crash while uploading images +- Fixed crash while loading contributions +- Fixed sporadic issue with date image was taken + +## v2.10.0 +- Added option to search for places that need pictures in any location +- Added coordinate check for images submitted via Nearby +- Added news about ongoing campaigns +- Easy retry for failed uploads +- Javadocs for Nearby package +- Optimized Nearby query for faster loading +- Allow users to dismiss notifications +- Various bugfixes for Explore, Notifications and Nearby +- Fixed uploads getting stuck in "receiving shared content" phase +- Fixed empty notifications bell icon in main screen + +## v2.9.0 +- New main screen UI with Nearby tab +- New upload UI and flow +- Multiple uploads +- Send Log File revamp +- Fixed issues with wrong "image taken" date +- Fixed default zoom level in Nearby map +- Incremented target SDK to 27, with corresponding notification channel fix +- Removed several redundant libraries to reduce bloat + +## v2.8.5 +- Fixed issues with sporadic upload failures due to wrong mimeType + +## v2.8.4 +- Hotfix for constant upload crashes for Oreo users + +## v2.8.3 +- Fixed issues with session tokens not being cleared in 2FA, which should reduce p18 edit failures as well +- Fixed crash caused by bug in fetching revert count +- Fixed crash potentially caused by Traceur library + +## v2.8.2 +- Fixed bug with uploads sent via Share being given .jpeg extensions and overwriting files of the same name + +## v2.8.1 +- Fixed bug with category edits not being sent to server + +## v2.8.0 +- Fixed failed uploads by modifying auth token +- Fixed crashes during upload by storing file temporarily +- Added automatic Wikidata p18 edits upon Nearby upload +- Added Explore feature to browse other Commons images, including featured images +- Added Achievements feature to see current level and upload stats +- Added quiz for users with high deletion rates +- Added first run tutorial for Nearby +- Various small improvements to ShareActivity UI + +## v2.7.2 +- Modified subtext for "automatically get current location" setting to emphasize that it will reveal user's location + +## v2.7.1 +- Fixed UI and permission issues with Nearby +- Fixed issue with My Recent Uploads being empty +- Fixed blank category issue when uploading directly from Nearby +- Various crash fixes + +## v2.7.0 +- New Nearby Places UI with direct uploads (and associated category suggestions) +- Added two-factor authentication login +- Added Notifications activity to display user talk messages +- Added real-time location tracking in Nearby +- Added "rate us", "translate", and FB link in About +- Improvements to UI of navigation drawer, tutorial, media details view, login activity and Settings +- Added option to nominate picture for deletion in media details view +- Too many bug and crash fixes to mention! + +## v2.6.7 +- Added null checks to prevent frequent crashes in ModificationsSyncAdapter + +## v2.6.6 +- Refactored Dagger to fix crashes encountered in production +- Fixed "?" displaying in description of Nearby places +- Database-related cleanup and tests +- Optimized dimens.xml +- Fixed issue where map opens with incorrect coordinates + +## v2.6.5 beta +- Changed "send log" feature to only send logs to private Google group forum +- Switched to using Wikimedia maps server instead of Mapbox for privacy reasons +- Removed event logging from app for privacy reasons +- Fixed crash caused by rapidly switching from Nearby map to list while loading + +## v2.6.4 beta +- Excluded httpclient and commons-logging to fix release build errors +- Fixed crashes caused by Fresco and Dagger + +## v2.6.3 beta +- Same as 2.6.2 except with localizations added for Google Code-In + +## v2.6.2 beta +- Reverted temporarily to last stable version while working on crash fix + +## v2.6.1 beta +- Failed attempt to fix crashes in release build with the previous beta release + +## v2.6.0 beta +- Multiple bugfixes for location updates and list/map loading in Nearby +- Multiple fixes for various crashes and memory leaks +- Added several unit tests +- Modified About page to include WMF disclaimer and modified Privacy Policy link to point to our individual privacy policy +- Added option for users to send logs to developers (has to be manually activated by user) +- Converted PNGs to WebPs +- Improved login screen with new design and privacy policy link +- Improved category display, if a category has an exact name entered, it will be shown first +- New UI for Nearby list +- Added product flavors for production and the beta-cluster Wikimedia servers +- Various improvements to navigation flow and backstack + +## v2.5.0 beta +- Added one-time popup for beta users to provide feedback on IEG renewal proposal +- Added link to Commons policies in ShareActivity +- Various string fixes +- Switched to using vector icons for map markers +- Added filter for irrelevant categories +- Fixed various crashes +- Incremented target SDK to 25 +- Improved appearance of navigation drawer +- Replaced proprietary app image in tutorial with one that isn't Telegram +- Fixed camera issue with FileProvider +- Added RxJava library, migrated to Java 8 +- Various code and continuous integration optimizations + +## v2.4.2 beta +- Added option to launch tutorial again from nav drawer +- Added marker for current location in Nearby map +- Fixed various strings +- Added check for location permissions when launching Nearby +- Temporary fix for API 25 camera crash +- App should now display accurate upload count +- Updated Gradle from 3.3 to 4.0 + +## v2.4.1 beta +- Fixed crash with uploading multiple photos +- Fixed memory leaks +- Fixed issues with Nearby places list and map + +## v2.4 +- Fixed memory issue with loading contributions on main screen +- Deleted images don't show up on contributions list +- Added Fresco library for image loading and LeakCanary for memory profiling +- Added navigation drawer and overhauled action bar +- Added logout functionality +- Fixed various issues with map of Nearby places + +## v2.3 beta +- Add map of Nearby places +- Add overlay dialog when a Nearby place is tapped +- Set default number of uploads to display in Main activity as 100, and add option in Settings to change it +- Detect when 2FA is used for login and display message +- Display date uploaded and image coordinates in image details page +- Display message when GPS is turned off, and when no Nearby items are found + +## v2.2.2 +- Hotfix for Nearby localization issue + +## v2.2.1 +- Hotfix for Settings crash + +## v2.2 beta (will not be released to Production due to bugs with Settings) +- Revamped Nearby to query Wikidata by default instead of Wiki Needs Pictures +- Added action bar to About screen +- Fixed crash related to fragment transaction state loss +- Moved Feedback menu item below Settings +- Various code optimizations and refactoring + +## v2.1 +- Added beta opt in link to Settings +- Added Codacy and Butterknife support +- Added Light theme for day/outdoor use +- Added Material icons +- Reordered overflow menu items +- Added credits to About page +- Fixed lint issues +- Fixed various crashes + +## v2.0.2 +- Make "View in browser" direct to mobile website + +## v2.0.1 +- Disabled minify again (reenabling test failed) +- Hotfix for ShareAction bug + +## v2.0 +- Modified Share button in media details fragment to allow user to choose different apps +- Added CC-BY 4.0 and CC-BY-SA 4.0 to license options +- Added selection pane for licenses on title/desc screen +- Switched to using material design for login form fields +- Added Checkstyle support +- Reenabled minify in Gradle +- Other minor code optimizations + +## v1.44 +- Attempted fix for GPS suggestions issue + +## v1.43 +- Added translations for multiple languages +- Minor code optimization + +## v1.42 +- Fixed language mappings; successful translatewiki integration +- Various translations added + +## v1.41 +- Bumped min SDK and removed escaped characters for translatewiki.net integration +- Added check for whether file already exists on Commons + +## v1.40 +- Added new pages to tutorial + +## v1.39 +- Fix for Korean translations crash +- Various minor fixes + +## v1.38 +- Added filter for suggested categories containing years (other than current or previous year) +- Attempted fix for issues with categories not being saved + +## v1.37 +- Added category suggestions based on entered title + +## v1.36 +- Fixed Ukranian translations + +## v1.35 +- Fixed issues with GPS category suggestions + +## v1.34 +- Added button to use previous title/desc + +## v1.33 +- Fixed crash when back button pressed before Nearby list is loaded +- Fixed crash when Nearby list is loaded without network connection +- Added no args constructor for GPS category suggestions + +## v1.32 +- Use Quadtree source instead of JAR, for F-Droid compatibility +- Fixed GPS extractor not being called + +## v1.31 +- Fixed bug with geolocation category suggestions not being displayed +- Fixed bug with (0,0) being recorded as image location occasionally + +## v1.30 +- Fixed {{Location|null}} template bug + +## v1.29 +- Added new icons to Nearby +- Added link to website on About + +## v1.28 +- Added geocoding template from GPS data stored in image +- Fixed bug with doubled list view in Nearby +- Further attempts to reduce overwrites + +## v1.27 +- New feature: List of nearby places without photos + +## v1.26 +- Fixed bug with overwriting files when multiple images selected + +## v1.25 +- Added in-app signup feature for new users +- Fixed crash when reading GPS coordinates + +## v1.24 +- Moved from bits/event.gif to wikimedia/beacon +- Fixed issue with needing to tap gallery again after giving permissions + +## v1.23 +- Added warning if image is submitted without categories +- Added check if back button is pressed at category selection screen + +## v1.22 +- Fixed various crashes +- Crash reports now go to private mailing list to protect user info + +## v1.21 +- Fixed Google Photos multiple share crash + +## v1.20 +- Hotfix for data=null crash + +## v1.19 +- Fixed adapter crash +- Attempt at fixing Google Photos crash + +## v1.18 +- Fixed various crashes +- Fixed camera and gallery for API 23 + +## v1.17 +- Fixed various crashes +- Fixed 'Desc/license/categories empty' bug + +## v1.16 +- Fixed various crashes +- Reduced APK size +- Fixed 'waiting for first sync' bug + +## v1.15 +- Added material design logo + +## v1.14 +- Migrated to Gradle +- Fixed API 23 permission crash +- Fixed "Template:According to EXIF data" analyzing EXIF data incorrectly + +## v1.13 +- Fixed prettyLicense and mediaUri crashes + +## v1.12 +- Further bug fixes for Polish language +- Added Javadocs + +## v1.11 +- Bugfix for Polish language crash + +## v1.10 +- Bugfix for null location crash + +## v1.9 +- Bugfix for null pages array crash +- New feature: Added option to use GPS to find nearby categories if picture is not geotagged + +## v1.8 +- New feature: Improved category search function (not limited to prefix search now) + +## v1.7 +- Fixed bug with uploading images in Marshmallow +- Fixed links in About page + +## v1.6 +- Bugfix for invalid images + +## v1.5 +- Caches area and associated categories +- Increased search radius for nearby categories + +## v1.4 +- New feature: Suggests nearby Commons categories + +## v1.3 +- Removed 'send usage reports' setting +- Fixed package naming issue +- Added 'sign up' button +- Removed unused 'campaigns' shortcut + +## v1.0 beta 11 +- New Launcher Icon +- Fix bug with licensing templates +- i18n updates + ## v1.0 beta 10 - Successfully reached double digit beta number - Honeycomb fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..d8f293a25 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +Thanks for considering to contribute to this project! A few guidelines for +people who want to contribute their code to this software are documented in +[this project's Wiki](https://github.com/commons-app/apps-android-commons/wiki/Contributing-Guidelines). +If you're not sure where to start head on to [this wiki page](https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome!). + +Here's a gist of the guidelines, + +1. Make separate commits for logically separate changes + +2. Describe your changes well in the commit message + +The first line of the commit message should be a short description of what has +changed. It is also good to prefix the first line with "area: " where the "area" +is a filename or identifier for the general area of the code being modified. +The body should provide a meaningful commit message. + +1. Write Javadocs + + We require contributors to include Javadocs for all new methods and classes + submitted via PRs (after 1 May 2018). This is aimed at making it easier for + new contributors to dive into our codebase, especially those who are new to + Android development. A few things to note: + + - This should not replace the need for code that is easily-readable in + and of itself + - Please make sure that your Javadocs are reasonably descriptive, not just + a copy of the method name + - Please do not use `@author` tags - we aim for collective code ownership, + and if needed, Git allows us to see who wrote something without needing + to add these tags (`git blame`) + +2. Write tests for your code (if possible) + +3. Make sure the Wiki pages don't become stale by updating them (if needed) + +### Further reading + +* [Importance of good commit messages](https://blog.oozou.com/commit-messages-matter-60309983c227?gi=c550a10d0f67) diff --git a/CREDITS b/CREDITS index c3661da74..3fe6b00d0 100644 --- a/CREDITS +++ b/CREDITS @@ -4,7 +4,1005 @@ their contribution to the product. * Brion Vibber * Niklas Laxström +* Prateek Saxena * Sage Ross * Siebrand Mazeland * Translatewiki.net Translators https://translatewiki.net/wiki/Special:ListUsers/translator * Yuvi Panda +* Nicolas Raoul +* Stephen Niedzielski +* Josephine Lim +* Adam Jones +* Yusuke Matsubara +* Tobias Schönberg +* Brian MacIntosh +* Veyndan Stuart +* Vivek Maskara +* Neslihan Turan +* Wikimedia Czech Republic (host for the Prague pre-hackathon 2017) +* Vojtěch Dostál +* Dinu Kumarasiri +* Dmitry Brant +* Adam Shorland +* John Lubbock +* Mikel Pascual +* Jan Piotrowski +* Bruke Mekuria Mulugeta +* Paul Hawke +* Vishan Seru +* Abhishek Poonia +* Ayushi Negi +* Harisanker Pradeep +* Hassan Ismaeel +* Jatin Rao +* Meghna Gupta +* S Balakrishnan +* Suchit Kar +* Tanvi Dadu +* Ujjwal Agrawal +* Mansi Agarwal +* Siddharth Vaish +* Ashish Kumar +* Ilgaz Er +* Alicia Bendz +* Kaartic Sivaraam +* Vanshika Arora +* Seán Mac Gillicuddy + +3rd party open source libraries used: +* Butterknife +* GSON +* Timber + +3rd party open source apps from which significant code has been reused: +* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia + +=========================================================================== + +The Wikimedia Commons Android app uses portions of MapBox. + +mapbox-gl-native copyright (c) 2014-2018 Mapbox. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +=========================================================================== + +Mapbox GL uses portions of Android Gesture Detectors Framework. + +Copyright (c) 2012, Almer Thie + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +=========================================================================== + +Mapbox GL uses portions of Android Support Library. + +Copyright (c) 2005-2013, The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=========================================================================== + +Mapbox GL uses portions of Boost. + +Distributed under the Boost Software License, Version 1.0. + +http://www.boost.org/LICENSE_1_0.txt + +=========================================================================== + +Mapbox GL uses portions of Clipper. + +Author : Angus Johnson +Version : 6.1.3a +Date : 22 January 2014 +Website : http://www.angusj.com +Copyright : Angus Johnson 2010-2014 + +License: +Use, modification & distribution is subject to Boost Software License Ver 1. +http://www.boost.org/LICENSE_1_0.txt + +Attributions: +The code in this library is an extension of Bala Vatti's clipping algorithm: +"A generic solution to polygon clipping" +Communications of the ACM, Vol 35, Issue 7 (July 1992) pp 56-63. +http://portal.acm.org/citation.cfm?id=129906 + +Computer graphics and geometric modeling: implementation and algorithms +By Max K. Agoston +Springer; 1 edition (January 4, 2005) +http://books.google.com/books?q=vatti+clipping+agoston + +See also: +"Polygon Offsetting by Computing Winding Numbers" +Paper no. DETC2005-85513 pp. 565-575 +ASME 2005 International Design Engineering Technical Conferences +and Computers and Information in Engineering Conference (IDETC/CIE2005) +September 24-28, 2005 , Long Beach, California, USA +http://www.me.berkeley.edu/~mcmains/pubs/DAC05OffsetPolygon.pdf + +=========================================================================== + +Mapbox GL uses portions of BugshotKit. + +The MIT License (MIT) + +Copyright (c) 2014 marcoarment + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of CSS Color Parser. + +(c) Dean McNamee , 2012. +C++ port by Konstantin Käfer , 2014. + +https://github.com/deanm/css-color-parser-js +https://github.com/kkaefer/css-color-parser-cpp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of GLFW. + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2010 Camilla Berglund + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. + +=========================================================================== + +Mapbox GL uses portions of libc++. + +The libc++ library is dual licensed under both the University of Illinois +"BSD-Like" license and the MIT license. As a user of this code you may choose +to use it under either license. As a contributor, you agree to allow your code +to be used under both. + +Full text of the relevant licenses is included below. + +==== + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2015 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + +* Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +==== + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of libcurl. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2015, Daniel Stenberg, . + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. + +=========================================================================== + +Mapbox GL uses portions of libjpeg-turbo. + +This software is based in part on the work of the Independent JPEG Group. + +Copyright (C)2009-2015 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +TurboJPEG/LJT: this implements the TurboJPEG API using libjpeg or libjpeg-turbo + +=========================================================================== + +Mapbox GL uses portions of libpng. + +This copy of the libpng notices is provided for your convenience. In case of +any discrepancy between this copy and the notices in the file png.h that is +included in the libpng distribution, the latter shall prevail. + +COPYRIGHT NOTICE, DISCLAIMER, and LICENSE: + +If you modify libpng you may insert additional notices immediately following +this sentence. + +This code is released under the libpng license. + +libpng versions 1.0.7, July 1, 2000, through 1.6.18, July 23, 2015, are +Copyright (c) 2000-2002, 2004, 2006-2015 Glenn Randers-Pehrson, and are +distributed according to the same disclaimer and license as libpng-1.0.6 +with the following individuals added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of the + library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is with + the user. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, and are distributed according +to the same disclaimer and license as libpng-0.96, with the following +individuals added to the list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, and are +distributed according to the same disclaimer and license as libpng-0.88, +with the following individuals added to the list of Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing Authors +and Group 42, Inc. disclaim all warranties, expressed or implied, +including, without limitation, the warranties of merchantability and of +fitness for any purpose. The Contributing Authors and Group 42, Inc. +assume no liability for direct, indirect, incidental, special, exemplary, +or consequential damages, which may result from the use of the PNG +Reference Library, even if advised of the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + +1. The origin of this source code must not be misrepresented. + +2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + +3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, without +fee, and encourage the use of this source code as a component to +supporting the PNG file format in commercial products. If you use this +source code in a product, acknowledgment is not required but would be +appreciated. + +=========================================================================== + +Mapbox GL uses portions of libuv. + +libuv is part of the Node project: http://nodejs.org/ +libuv may be distributed alone under Node's license: + +==== + +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. + +==== + +This license applies to all parts of libuv that are not externally +maintained libraries. + +The externally maintained libraries used by libuv are: + +- tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license. + +- inet_pton and inet_ntop implementations, contained in src/inet.c, are + copyright the Internet Systems Consortium, Inc., and licensed under the ISC + license. + +- stdint-msvc2008.h (from msinttypes), copyright Alexander Chemeris. Three + clause BSD license. + +- pthread-fixes.h, pthread-fixes.c, copyright Google Inc. and Sony Mobile + Communications AB. Three clause BSD license. + +- android-ifaddrs.h, android-ifaddrs.c, copyright Berkeley Software Design + Inc, Kenneth MacKay and Emergya (Cloud4all, FP7/2007-2013, grant agreement + n° 289016). Three clause BSD license. + +=========================================================================== + +Mapbox GL uses portions of libzip. + +Copyright (C) 1999-2014 Dieter Baron and Thomas Klausner + +The authors can be contacted at + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +3. The names of the authors may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +=========================================================================== + +Mapbox GL uses portions of LOST. + +Copyright (c) 2014 Mapzen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=========================================================================== + +Mapbox GL uses portions of the Mapbox iOS SDK, which was derived from the +Route-Me open source project, including the Alpstein fork of it. + +The Route-Me license appears below. + +Copyright (c) 2008-2013, Route-Me Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +=========================================================================== + +Mapbox GL uses portions of nunicode. + +Copyright (c) 2013 Aleksey Tulinov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of OkHTTP. + +Copyright 2014 Square, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=========================================================================== + +Mapbox GL uses portions of OpenSSL. + +LICENSE ISSUES +============== + +The OpenSSL toolkit stays under a dual license, i.e. both the conditions of +the OpenSSL License and the original SSLeay license apply to the toolkit. +See below for the actual license texts. Actually both licenses are BSD-style +Open Source licenses. In case of any license issues related to OpenSSL +please contact openssl-core@openssl.org. + +OpenSSL License +--------------- + +Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +3. All advertising materials mentioning features or use of this + software must display the following acknowledgment: + "This product includes software developed by the OpenSSL Project + for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + +4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + endorse or promote products derived from this software without + prior written permission. For written permission, please contact + openssl-core@openssl.org. + +5. Products derived from this software may not be called "OpenSSL" + nor may "OpenSSL" appear in their names without prior written + permission of the OpenSSL Project. + +6. Redistributions of any form whatsoever must retain the following + acknowledgment: + "This product includes software developed by the OpenSSL Project + for use in the OpenSSL Toolkit (http://www.openssl.org/)" + +THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY +EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR +ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. + +This product includes cryptographic software written by Eric Young +(eay@cryptsoft.com). This product includes software written by Tim +Hudson (tjh@cryptsoft.com). + +Original SSLeay License +----------------------- + +Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) +All rights reserved. + +This package is an SSL implementation written +by Eric Young (eay@cryptsoft.com). +The implementation was written so as to conform with Netscapes SSL. + +This library is free for commercial and non-commercial use as long as +The following conditions are aheared to. The following conditions +apply to all code found in this distribution, be it the RC4, RSA, +lhash, DES, etc., code; not just the SSL code. The SSL documentation +included with this distribution is covered by the same copyright terms +except that the holder is Tim Hudson (tjh@cryptsoft.com). + +Copyright remains Eric Young's, and as such any Copyright notices in +the code are not to be removed. +If this package is used in a product, Eric Young should be given attribution +as the author of the parts of the library used. +This can be in the form of a textual message at program startup or +in documentation (online or textual) provided with the package. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. All advertising materials mentioning features or use of this software + must display the following acknowledgement: + "This product includes cryptographic software written by + Eric Young (eay@cryptsoft.com)" + The word 'cryptographic' can be left out if the rouines from the library + being used are not cryptographic related :-). +4. If you include any Windows specific code (or a derivative thereof) from + the apps directory (application code) you must include an acknowledgement: + "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + +THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +The licence and distribution terms for any publically available version or +derivative of this code cannot be changed. i.e. this code cannot simply be +copied and put under another distribution licence +[including the GNU Public Licence.] + +=========================================================================== + +Mapbox GL uses portions of RapidJSON. + +Tencent is pleased to support the open source community by making RapidJSON +available. + +Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights +reserved. + +If you have downloaded a copy of the RapidJSON binary from Tencent, please note +that the RapidJSON binary is licensed under the MIT License. If you have +downloaded a copy of the RapidJSON source code from Tencent, please note that +RapidJSON source code is licensed under the MIT License, except for the third- +party components listed below which are subject to different license terms. +Your integration of RapidJSON into your own projects may require compliance with +the MIT License, as well as the other licenses applicable to the third-party +components included within RapidJSON. To avoid the problematic JSON license in +your own projects, it's sufficient to exclude the bin/jsonchecker/ directory, as +it's the only code under the JSON license. A copy of the MIT License is included +in this file. + +Other dependencies and licenses: + +Open Source Software Licensed Under the BSD License: +-------------------------------------------------------------------- + +The msinttypes r29 +Copyright (c) 2006-2013 Alexander Chemeris +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of copyright holder nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Open Source Software Licensed Under the JSON License: +-------------------------------------------------------------------- + +json.org +Copyright (c) 2002 JSON.org +All Rights Reserved. + +JSON_checker +Copyright (c) 2002 JSON.org +All Rights Reserved. + +Terms of the JSON License: +--------------------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +The Software shall be used for Good, not Evil. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Terms of the MIT License: +-------------------------------------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of Reachability. + +Copyright (c) 2011, Tony Million. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +=========================================================================== + +Mapbox GL uses portions of SQLite. + +2001 September 15 + +The author disclaims copyright to this source code. In place of +a legal notice, here is a blessing: + + May you do good and not evil. + May you find forgiveness for yourself and forgive others. + May you share freely, never taking more than you give. + +=========================================================================== + +Mapbox GL uses portions of SVPulsingAnnotationView. + +Copyright (c) 2013, Sam Vermette + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +=========================================================================== + +Mapbox GL uses portions of zlib. + +Acknowledgments: + +The deflate format used by zlib was defined by Phil Katz. The deflate and +zlib specifications were written by L. Peter Deutsch. Thanks to all the +people who reported problems and suggested various improvements in zlib; they +are too numerous to cite here. + +Copyright notice: + +(C) 1995-2013 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu + +=========================================================================== + +Mapbox GL uses portions of Realm Objective-C. + +Copyright 2015 Realm Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/README.md b/README.md index 5b83bd247..37f1a7872 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,58 @@ -# The Wikimedia Commons Android App # +# Wikimedia Commons Android app +![GitHub issue custom search](https://img.shields.io/github/issues-search?label=%22good%20first%20issue%22%20issues&query=repo%3Acommons-app%2Fapps-android-commons%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) +[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain) +[![Preview the app](https://img.shields.io/badge/Preview-Appetize.io-orange.svg)](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw) +[![codecov](https://codecov.io/gh/commons-app/apps-android-commons/branch/master/graph/badge.svg)](https://codecov.io/gh/commons-app/apps-android-commons) -## Build Requirements ## +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]. -1. [Android SDK][1] (Level 15) -2. [Maven][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! :-) -## Build Instructions ## + +Get it on F-Droid + +Get it on Google Play -1. Set the environment variable `ANDROID_HOME` to be the path to your Android SDK -2. Run `mvn install` to build -3. Run `mvn android:deploy` to deploy to a device -4. There is no step 4 +## Documentation -**Note**: Currently uses a bunch of dependencies that are staged at `yuvi.in/blog/maven`. Will be migrated to either [Maven Central][4] or a Wikimedia staging server soon. +Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike: -## Set Up IntelliJ for Commons Android App Development ## +* [User Documentation][5] +* [Contributor Documentation][6] + * [Volunteers Welcome!][7] +* [Developer Documentation][8] + * [Libraries Used][9] -### Import and Compile Commons Android App ## +## Contributors ## -[Download IntelliJ][6] +Thank you all for your work! -1. Clone the repository. -2. Open IntelliJ. -3. Import Project: - File -> Import Project - or - Select 'Import Project' from the Quick Start menu -4. Navigate to the folder with the cloned repository and press 'OK'. -5. Select 'Import Project from external model' -> 'Maven' and press 'Next'. -6. Make sure 'Search for projects recursively' and 'Import Maven projects automatically' are checked. Select 'Next'. -7. This section needs no modification. Select 'Next'. -8. This section needs no modification. Select 'Next'. -9. Make sure the 'Android SDK home path' points to the 'android-sdk' folder. If the dropdown next to 'Java SDK' is empty, hit the '+' button avobe the sidebar and select 'JDK'. Navigate to your jdk folder, select it, and hit 'OK'. Now select the newly added JDK and hit 'Next'. -10. This section needs no modifications. Select 'Next'. -11. Select 'Finish'. -12. After the program opens select 'Make project' - there should be errors. -13. Near the top of the file that is opened up, one of the offending lines should be "import android.support.v4.app.FragmentActivity;" - put your cursor on that line and hit 'alt'/'option'+'enter' to bring up the AutoFix dialog. Select the 'compatibility' option. -14. Select 'Make project' again. It should compile successfully. +| [
misaochan](https://github.com/misaochan) | [
translatewiki](https://github.com/translatewiki) | [
neslihanturan](https://github.com/neslihanturan) | [
yuvipanda](https://github.com/yuvipanda) | [
nicolas-raoul](https://github.com/nicolas-raoul) | +| :---: | :---: | :---: | :---: | :---: | +| [
psh](https://github.com/psh) | [
domdomegg](https://github.com/domdomegg) | [
maskaravivek](https://github.com/maskaravivek) | [
madhurgupta10](https://github.com/madhurgupta10) | [
ashishkumar468](https://github.com/ashishkumar468) | +| [
bvibber](https://github.com/bvibber) | [
whym](https://github.com/whym) | [
akaita](https://github.com/akaita) | [
sivaraam](https://github.com/sivaraam) | [
veyndan](https://github.com/veyndan) | +| [
ujjwalagrawal17](https://github.com/ujjwalagrawal17) | [
macgills](https://github.com/macgills) | [
amire80](https://github.com/amire80) | [
dbrant](https://github.com/dbrant) | [
vanshikaarora](https://github.com/vanshikaarora) | +| [
RitikaPahwa4444](https://github.com/RitikaPahwa4444) | [
Ayan-10](https://github.com/Ayan-10) | [
rohit9625](https://github.com/rohit9625) | [
shashankiitbhu](https://github.com/shashankiitbhu) | [
Pratham2305](https://github.com/Pratham2305) | +| [
parneet-guraya](https://github.com/parneet-guraya) | [
sandarumk](https://github.com/sandarumk) | [
tanvidadu](https://github.com/tanvidadu) | [
cypherop](https://github.com/cypherop) | [
Prince-kushwaha](https://github.com/Prince-kushwaha) | + + + +.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors). ## License ## -This software is licensed under the [Apache License][5]. - -## Bugs? ## - -This software has no bugs. You can dispute this statement at [bugzilla][3] - -## Code Structure ## - -Key breakdowns: - -Activities started within the UI: -* ContributionsActivity (ContributionsListFragment, MediaDetailPagerFragment, MediaDetailFragment) - main "my uploads" list and detail view -* LoginActivity - login screen when setting up an account -* SettingsActivity - settings screen -* AboutActivity - about screen - -Activities receiving intents: -* ShareActivity (SingleUploadFragment, CategorizationFragment) - handles receiving a file from another app, accepting a title/desc, and slating it for upload -* MultipleShareActivity (MultipleUploadListFragment, CategorizationFragment) - handles receiving a batch of multiple files from another app, accepting a title/desc, and slating them for upload - -Services: -* WikiAccountAuthenticatorService - authentication service -* UploadService - performs actual file uploads in background -* ContributionsSyncService - polls for updated contributions list from server -* ModificationsSyncService - pushes category additions up to server - -Content providers: -* ContributionsContentProvider - private storage for local copy of user's contribution list -* ModificationsContentProvider - private storage for pending category and template modifications -* CategoryContentProvider - private storage for recently used categories +This software is open source, licensed under the [Apache License 2.0][10]. -## On-Device Storage ## +[1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons +[2]: https://commons-app.github.io/ +[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream -Account credentials are encapsulated in an account provider. Currently only one Wikimedia Commons account is supported at a time. (Question: what is the actual storage for credentials?) +[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation +[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation +[6]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#️-contributor-documentation +[7]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Volunteers-welcome!.md#volunteers-welcome +[8]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-developer-documentation +[9]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Libraries-used.md#libraries-used -Preferences are stored in Android's SharedPreferences. - -Information about past and pending uploads is stored in the Contributions content provider, which uses an SQLite database on the backend. - -A list of recently-used categories is stored in the Categories content provider, which uses an SQLite database on the backend. - -Captured files are not currently stored within the app, but are passed by content: or file: URI from other apps. - -Thumbnail images are not currently cached. (?) - - - - - - -[1]: https://developer.android.com/sdk/index.html -[2]: https://maven.apache.org/ -[3]: https://bugzilla.wikimedia.org/enter_bug.cgi?product=Commons%20App -[4]: http://search.maven.org/ -[5]: https://www.apache.org/licenses/LICENSE-2.0 -[6]: http://www.jetbrains.com/idea/download/index.html +[10]: https://www.apache.org/licenses/LICENSE-2.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..41788128c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,447 @@ +import java.util.Properties +import java.io.ByteArrayOutputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.kotlin.parcelize) +} + +apply(from = "$rootDir/jacoco.gradle") + +val isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file("../play.p12").exists() + +if (isRunningOnTravisAndIsNotPRBuild) { + apply(plugin = "com.github.triplet.play") +} + +android { + namespace = "fr.free.nrw.commons" + compileSdk = 35 + + defaultConfig { + applicationId = "fr.free.nrw.commons" + minSdk = 21 + targetSdk = 35 + versionCode = 1059 + versionName = "6.1.0" + + setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + + multiDexEnabled = true + + vectorDrawables { + useSupportLibrary = true + } + } + + sourceSets { + getByName("test") { + // Use kotlin only in tests (for now) + java.srcDirs("src/test/kotlin") + + // Use main assets and resources in test + assets.srcDirs("src/main/assets") + resources.srcDirs("src/main/resources") + } + } + + signingConfigs { + create("release") { + // Configure keystore based on env vars in Travis for automated alpha builds + if(isRunningOnTravisAndIsNotPRBuild) { + storeFile = file("../nr-commons.keystore") + storePassword = System.getenv("keystore_password") + keyAlias = System.getenv("key_alias") + keyPassword = System.getenv("key_password") + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt") + testProguardFile("test-proguard-rules.txt") + + signingConfig = signingConfigs.getByName("debug") + if (isRunningOnTravisAndIsNotPRBuild) { + signingConfig = signingConfigs.getByName("release") + } + } + debug { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt") + testProguardFile("test-proguard-rules.txt") + + versionNameSuffix = "-debug-" + getBranchName() + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } + + configurations.all { + resolutionStrategy { + force("androidx.annotation:annotation:1.1.0") + force("com.jakewharton.timber:timber:4.7.1") + force("androidx.fragment:fragment:1.3.6") + } + exclude(module = "okhttp-ws") + } + + flavorDimensions += "tier" + productFlavors { + create("prod") { + dimension = "tier" + applicationId = "fr.free.nrw.commons" + + buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"") + buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"") + buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"") + buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"") + buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"") + buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"") + buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"") + buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"") + buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.org\"") + buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"") + buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"") + buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"") + buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"") + buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"") + buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"") + buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"") + buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"") + buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"") + buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"") + buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"") + buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\"") + buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\"") + buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.recentlanguages.contentprovider\"") + buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\"") + buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\"") + buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.items.contentprovider\"") + buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"") + buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"") + buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"") + buildConfigField("String", "DEPICTS_PROPERTY", "\"P180\"") + buildConfigField("String", "CREATOR_PROPERTY", "\"P170\"") + } + + create("beta") { + dimension = "tier" + applicationId = "fr.free.nrw.commons.beta" + + // What values do we need to hit the BETA versions of the site / api ? + buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"") + buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"") + buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"") + buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"") + buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"") + buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"") + buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"") + buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"") + buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"") + buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"") + buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"") + buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"") + buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"") + buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"") + buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"") + buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"") + buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"") + buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"") + buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"") + buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"") + buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\"") + buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\"") + buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.beta.recentlanguages.contentprovider\"") + buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\"") + buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\"") + buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.items.contentprovider\"") + buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"") + buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"") + buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"") + buildConfigField("String", "DEPICTS_PROPERTY", "\"P245962\"") + buildConfigField("String", "CREATOR_PROPERTY", "\"P253075\"") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + buildConfig = true + viewBinding = true + compose = true + } + buildToolsVersion = buildToolsVersion + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + jniLibs { + excludes += listOf("META-INF/androidx.*") + } + resources { + excludes += listOf( + "META-INF/androidx.*", + "META-INF/proguard/androidx-annotations.pro", + "/META-INF/LICENSE.md", + "/META-INF/LICENSE-notice.md" + ) + } + } + testOptions { + animationsDisabled = true + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + unitTests.all { + it.jvmArgs("-noverify") + } + } + lint { + abortOnError = false + disable += listOf("MissingTranslation", "ExtraTranslation") + } +} + +dependencies { + // Utils + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.retrofit.adapter.rxjava) + implementation(libs.rxandroid) + implementation(libs.rxjava) + implementation(libs.rxbinding) + implementation(libs.rxbinding.appcompat) + implementation(libs.facebook.fresco) + implementation(libs.facebook.fresco.middleware) + implementation(libs.apache.commons.lang3) + + // UI + implementation("${libs.viewpagerindicator.library.get()}@aar") + implementation(libs.photoview) + implementation(libs.android.sdk) + implementation(libs.android.plugin.scalebar) + + implementation(libs.timber) + implementation(libs.android.material) + implementation(libs.dexter) + + // Jetpack Compose + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.ui.viewbinding) + implementation(libs.androidx.material3) + implementation(libs.androidx.foundation) + implementation(libs.androidx.foundation.layout) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.adapterdelegates4.kotlin.dsl.viewbinding) + implementation(libs.adapterdelegates4.pagination) + implementation(libs.androidx.paging.runtime.ktx) + testImplementation(libs.androidx.paging.common.ktx) + implementation(libs.androidx.paging.rxjava2.ktx) + implementation(libs.androidx.recyclerview) + + // Logging + implementation(libs.acra.dialog) + implementation(libs.acra.mail) + implementation(libs.slf4j.api) + implementation(libs.logback.android.classic) { + exclude(group = "com.google.android", module = "android") + } + implementation(libs.logging.interceptor) + + // Dependency injector + implementation(libs.dagger.android) + implementation(libs.dagger.android.support) + kapt(libs.dagger.android.processor) + kapt(libs.dagger.compiler) + annotationProcessor(libs.dagger.android.processor) + + implementation(libs.kotlin.reflect) + + //Mocking + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.core) + testImplementation(libs.powermock.module.junit) + testImplementation(libs.powermock.api.mockito) + testImplementation(libs.mockk) + + // Unit testing + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.runner) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.test.rules) + testImplementation(libs.mockwebserver) + testImplementation(libs.livedata.testing.ktx) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.soloader) + testImplementation(libs.kotlinx.coroutines.test) + debugImplementation(libs.androidx.fragment.testing) + testImplementation(libs.commons.io) + + // Android testing + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.espresso.contrib) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.annotation) + androidTestImplementation(libs.mockwebserver) + androidTestImplementation(libs.androidx.uiautomator) + + // Debugging + debugImplementation(libs.leakcanary.android) + + // Support libraries + implementation(libs.androidx.browser) + implementation(libs.androidx.cardview) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.exifinterface) + implementation(libs.recyclerview.fastscroll) + + //swipe_layout + implementation(libs.swipelayout.library) + + //Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.rxjava) + kapt(libs.androidx.room.compiler) + + // Preferences + implementation(libs.androidx.preference) + implementation(libs.androidx.preference.ktx) + + //Android Media + implementation(libs.juanitobananas.androidDmediaUtil) + implementation(libs.androidx.multidex) + + // Kotlin + coroutines + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.work.runtime) + implementation(libs.kotlinx.coroutines.rx2) + testImplementation(libs.androidx.work.testing) + + //Glide + implementation(libs.glide) + annotationProcessor(libs.glide.compiler) + kaptTest(libs.androidx.databinding.compiler) + kaptAndroidTest(libs.androidx.databinding.compiler) + + implementation(libs.coordinates2country.android) { + exclude(group = "com.google.android", module = "android") + } + + //OSMDroid + implementation(libs.osmdroid.android) + constraints { + implementation(libs.kotlin.stdlib.jdk7) { + because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") + } + implementation(libs.kotlin.stdlib.jdk8) { + because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") + } + } +} + +tasks.register("disableAnimations") { + val adb = "${System.getenv("ANDROID_HOME")}/platform-tools/adb" + commandLine(adb, "shell", "settings", "put", "global", "window_animation_scale", "0") + commandLine(adb, "shell", "settings", "put", "global", "transition_animation_scale", "0") + commandLine(adb, "shell", "settings", "put", "global", "animator_duration_scale", "0") +} + +project.gradle.taskGraph.whenReady { + val connectedBetaDebugAndroidTest = tasks.named("connectedBetaDebugAndroidTest") + val connectedProdDebugAndroidTest = tasks.named("connectedProdDebugAndroidTest") + + connectedBetaDebugAndroidTest.configure { + dependsOn("disableAnimations") + } + connectedProdDebugAndroidTest.configure { + dependsOn("disableAnimations") + } +} + +fun getTestUserName(): String? { + val propFile = rootProject.file("./local.properties") + val properties = Properties() + propFile.inputStream().use { properties.load(it) } + return properties.getProperty("TEST_USER_NAME") +} + +fun getTestPassword(): String? { + val propFile = rootProject.file("./local.properties") + val properties = Properties() + propFile.inputStream().use { properties.load(it) } + return properties.getProperty("TEST_USER_PASSWORD") +} + +if (isRunningOnTravisAndIsNotPRBuild) { + configure { + track = "alpha" + userFraction = 1.0 + serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME") + serviceAccountCredentials = file("../play.p12") + + resolutionStrategy = "auto" + outputProcessor { // this: ApkVariantOutput + versionNameOverride = "$versionNameOverride.$versionCode" + } + } +} + +fun getBuildVersion(): String? { + return try { + val stdout = ByteArrayOutputStream() + exec { + commandLine("git", "rev-parse", "--short", "HEAD") + standardOutput = stdout + } + stdout.toString().trim() + } catch (e: Exception) { + null + } +} + +fun getBranchName(): String? { + return try { + val stdout = ByteArrayOutputStream() + exec { + commandLine("git", "rev-parse", "--abbrev-ref", "HEAD") + standardOutput = stdout + } + stdout.toString().trim() + } catch (e: Exception) { + null + } +} diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt new file mode 100644 index 000000000..21c584ba9 --- /dev/null +++ b/app/proguard-rules.txt @@ -0,0 +1,100 @@ +-dontobfuscate +-ignorewarnings + +-dontnote ** +-dontwarn net.bytebuddy.** +-dontwarn org.mockito.** + +# --- Apache --- +-keep class org.apache.http.** { *; } +-dontwarn org.apache.** +# --- /Apache --- + +# --- Butter Knife --- +# Finder.castParam() is stripped when not needed and ProGuard notes it +# unnecessarily. When castParam() is needed, it's not stripped. e.g.: +# +# @OnItemSelected(value = R.id.history_entry_list) +# void foo(ListView bar) { +# L.d("baz"); +# } + +-dontnote butterknife.internal.** +# --- /Butter Knife --- + +# --- Retrofit2 --- +# Platform calls Class.forName on types which do not exist on Android to determine platform. +-dontnote retrofit2.Platform +# Platform used when running on Java 8 VMs. Will not be used at runtime. +-dontwarn retrofit2.Platform$Java8 +# Retain generic type information for use by reflection by converters and adapters. +-keepattributes Signature +# Retain declared checked exceptions for use by a Proxy instance. +-keepattributes Exceptions + +# Note: The model package right now seems to include some other classes that +# are not used for serialization / deserialization over Gson. Hopefully +# that's not a problem since it only prevents R8 from avoiding trimming +# of few more classes. +-keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; } +-keepclasseswithmembers class fr.free.nrw.commons.actions.** { *; } +-keepclasseswithmembers class fr.free.nrw.commons.auth.csrf.** { *; } +-keepclasseswithmembers class fr.free.nrw.commons.auth.login.** { *; } +-keepclasseswithmembers class fr.free.nrw.commons.wikidata.mwapi.** { *; } + +# --- /Retrofit --- + +# --- OkHttp + Okio --- +-dontwarn okhttp3.** +-dontwarn okio.** +# --- /OkHttp + Okio --- + +# --- Gson --- +# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg + +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { *; } + +# Prevent R8 from obfuscating project classes used by Gson for parsing +-keep class fr.free.nrw.commons.fileusages.** { *; } + +# Prevent proguard from stripping interface information from TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer +# --- /Gson --- + + +# --- /logback --- + +-keep class ch.qos.** { *; } +-keep class org.slf4j.** { *; } +-keepattributes *Annotation* + +-dontwarn ch.qos.logback.core.net.* + +# --- /acra --- +-keep class org.acra.** { *; } +-keepattributes SourceFile,LineNumberTable +-keepattributes *Annotation* + +# --- /recycler view --- +-keep class androidx.recyclerview.widget.RecyclerView { + public androidx.recyclerview.widget.RecyclerView$ViewHolder findViewHolderForPosition(int); +} +# --- Parcelable --- +-keepclassmembers class * implements android.os.Parcelable { + static ** CREATOR; +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt new file mode 100644 index 000000000..50dfe8e7f --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt @@ -0,0 +1,149 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import org.hamcrest.CoreMatchers +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AboutActivityTest { + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + Intents.init() + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + } + + @After + fun cleanUp() { + Intents.release() + } + + @Test + fun testBuildNumber() { + Espresso + .onView(ViewMatchers.withId(R.id.about_version)) + .check( + ViewAssertions.matches( + withText(getApplicationContext().getVersionNameWithSha()), + ), + ) + } + + @Test + fun testLaunchWebsite() { + Espresso.onView(ViewMatchers.withId(R.id.website_launch_icon)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.WEBSITE_URL), + ), + ) + } + + @Test + fun testLaunchFacebook() { + Espresso.onView(ViewMatchers.withId(R.id.facebook_launch_icon)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.anyOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), + IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME), + ), + ) + } + + @Test + fun testLaunchGithub() { + Espresso.onView(ViewMatchers.withId(R.id.github_launch_icon)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.GITHUB_REPO_URL), + ), + ) + } + + @Test + fun testLaunchAboutPrivacyPolicy() { + Espresso.onView(ViewMatchers.withId(R.id.about_privacy_policy)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL), + ), + ) + } + + @Test + fun testLaunchTranslate() { + Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) + val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"), + ), + ) + } + + @Test + fun testLaunchAboutCredits() { + Espresso.onView(ViewMatchers.withId(R.id.about_credits)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.CREDITS_URL), + ), + ) + } + + @Test + fun testLaunchUserGuide() { + Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.USER_GUIDE_URL), + ), + ) + } + + @Test + fun testLaunchAboutFaq() { + Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.FAQ_URL), + ), + ) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt new file mode 100644 index 000000000..9bfc9321b --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt @@ -0,0 +1,69 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation.ActivityResult +import android.content.Intent +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.auth.SignupActivity +import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.not +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginActivityTest { + @get:Rule + var activityRule = ActivityTestRule(LoginActivity::class.java) + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + Intents.init() + UITestHelper.skipWelcome() + intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null)) + } + + @After + fun cleanUp() { + Intents.release() + } + + @Test + fun testForgotPassword() { + Espresso.onView(ViewMatchers.withId(R.id.forgot_password)).perform(ViewActions.click()) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL), + ), + ) + } + + @Test + fun testSignupButton() { + Espresso.onView(ViewMatchers.withId(R.id.sign_up_button)).perform(ViewActions.click()) + Intents.intended(IntentMatchers.hasComponent(SignupActivity::class.java.name)) + } + + @Test + fun orientationChange() { + UITestHelper.changeOrientation(activityRule) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt new file mode 100644 index 000000000..3d2fc9e48 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt @@ -0,0 +1,214 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import com.google.gson.Gson +import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.notification.NotificationActivity +import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) + + @get:Rule + var mGrantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + "android.permission.ACCESS_FINE_LOCATION", + ) + + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + private lateinit var defaultKvStore: JsonKvStore + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + UITestHelper.loginUser() + UITestHelper.skipWelcome() + Intents.init() + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val storeName = context.packageName + "_preferences" + defaultKvStore = JsonKvStore(context, storeName, Gson()) + } + + @After + fun cleanUp() { + Intents.release() + } + + @Test + fun testNearby() { + Espresso + .onView( + Matchers.allOf( + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 1, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) + .check(matches(ViewMatchers.isDisplayed())) + UITestHelper.sleep(10000) + val actionMenuItemView2 = + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.list_sheet), + ViewMatchers.withContentDescription("List"), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 0, + ), + ViewMatchers.isDisplayed(), + ), + ) + actionMenuItemView2.perform(ViewActions.click()) + UITestHelper.sleep(1000) + } + + @Test + fun testExplore() { + Espresso + .onView( + Matchers.allOf( + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 2, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) + .check(matches(ViewMatchers.isDisplayed())) + UITestHelper.sleep(1000) + } + + @Test + fun testContributions() { + Espresso + .onView( + Matchers.allOf( + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 0, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) + .check(matches(ViewMatchers.isDisplayed())) + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withId(R.id.contributionImage), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.contributionsList), + 0, + ), + 1, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + val actionMenuItemView = + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.menu_bookmark_current_image), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 0, + ), + ViewMatchers.isDisplayed(), + ), + ) + actionMenuItemView.perform(ViewActions.click()) + UITestHelper.sleep(3000) + } + + @Test + fun testBookmarks() { + Espresso + .onView( + Matchers.allOf( + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 3, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + UITestHelper.sleep(1000) + } + + @Test + fun testNotifications() { + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withId(R.id.notifications), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 1, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) + Espresso.pressBack() + UITestHelper.sleep(1000) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt new file mode 100644 index 000000000..003fc0674 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt @@ -0,0 +1,67 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.rule.IntentsTestRule +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.profile.ProfileActivity +import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProfileActivityTest { + @get:Rule + var activityRule = IntentsTestRule(LoginActivity::class.java) + + private val device: UiDevice = UiDevice.getInstance(getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + UITestHelper.loginUser() + UITestHelper.skipWelcome() + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + } + + @Test + fun testProfile() { + onView( + Matchers.allOf( + ViewMatchers.withContentDescription("More"), + childAtPosition( + childAtPosition( + withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 4, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + onView(Matchers.allOf(withId(R.id.more_profile))).perform( + ViewActions.scrollTo(), + ViewActions.click(), + ) + device.swipe(1033, 1346, 531, 1346, 20) + UITestHelper.sleep(5000) + Intents.intended(hasComponent(ProfileActivity::class.java.name)) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt new file mode 100644 index 000000000..3f6487e47 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import fr.free.nrw.commons.review.ReviewActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReviewActivityTest { + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) + + @Test + fun orientationChange() { + UITestHelper.changeOrientation(activityRule) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt new file mode 100644 index 000000000..69ce412b9 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt @@ -0,0 +1,59 @@ +package fr.free.nrw.commons + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.explore.SearchActivity +import org.hamcrest.Matchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SearchActivityTest { + @get:Rule + var activityRule = ActivityTestRule(SearchActivity::class.java) + + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + } + + @Test + fun exploreActivityTest() { + val searchAutoComplete = + Espresso.onView( + Matchers.allOf( + UITestHelper.childAtPosition( + Matchers.allOf( + ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), + UITestHelper.childAtPosition( + ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), + 1, + ), + ), + 0, + ), + ViewMatchers.isDisplayed(), + ), + ) + searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) + UITestHelper.sleep(5000) + device.swipe(1000, 1400, 500, 1400, 20) + device.swipe(800, 1400, 600, 1400, 20) + device.swipe(800, 1400, 600, 1400, 20) + device.swipe(800, 1400, 600, 1400, 20) + device.swipe(800, 1400, 600, 1400, 20) + device.swipe(800, 1400, 600, 1400, 20) + UITestHelper.sleep(1000) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt new file mode 100644 index 000000000..ec132b447 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt @@ -0,0 +1,65 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.settings.SettingsActivity +import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsActivityLoggedInTest { + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) + + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + UITestHelper.loginUser() + UITestHelper.skipWelcome() + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + } + + @Test + fun testSettings() { + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withContentDescription("More"), + UITestHelper.childAtPosition( + UITestHelper.childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 4, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( + ViewActions.scrollTo(), + ViewActions.click(), + ) + Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) + UITestHelper.sleep(1000) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt new file mode 100644 index 000000000..c5a91cd56 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt @@ -0,0 +1,70 @@ +package fr.free.nrw.commons + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import com.google.gson.Gson +import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.settings.SettingsActivity +import org.hamcrest.CoreMatchers.allOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsActivityTest { + private lateinit var defaultKvStore: JsonKvStore + + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(SettingsActivity::class.java) + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + val context = InstrumentationRegistry.getInstrumentation().targetContext + val storeName = context.packageName + "_preferences" + defaultKvStore = JsonKvStore(context, storeName, Gson()) + } + + @Test + fun useAuthorNameTogglesOn() { + // Turn on "Use author name" preference if currently off + if (!defaultKvStore.getBoolean("useAuthorName", false)) { + Espresso + .onView( + allOf( + withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0), + ), + ).perform( + RecyclerViewActions.actionOnItemAtPosition(6, click()), + ) + } + // Check authorName preference is enabled + Espresso + .onView( + allOf( + withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0), + ), + ).check(matches(isEnabled())) + } + + @Test + fun orientationChange() { + UITestHelper.changeOrientation(activityRule) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt new file mode 100644 index 000000000..ebb06e4af --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt @@ -0,0 +1,205 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.view.View +import android.view.ViewGroup +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.rule.ActivityTestRule +import org.apache.commons.lang3.StringUtils +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.TypeSafeMatcher +import timber.log.Timber + +class UITestHelper { + companion object { + fun skipWelcome() { + try { + onView(ViewMatchers.withId(R.id.button_ok)) + .perform(ViewActions.click()) + // Skip tutorial + onView(ViewMatchers.withId(R.id.finishTutorialButton)) + .perform(ViewActions.click()) + } catch (ignored: NoMatchingViewException) { + } + } + + fun skipLogin() { + try { + // Skip Login + val htmlTextView = + onView( + Matchers.allOf( + ViewMatchers.withId(R.id.skip_login), + ViewMatchers.withText("Skip"), + ViewMatchers.isDisplayed(), + ), + ) + htmlTextView.perform(ViewActions.click()) + + val appCompatButton = + onView( + Matchers.allOf( + ViewMatchers.withId(android.R.id.button1), + ViewMatchers.withText("Yes"), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.buttonPanel), + 0, + ), + 3, + ), + ), + ) + appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) + } catch (ignored: NoMatchingViewException) { + } + } + + fun loginUser() { + try { + // Perform Login + sleep(3000) + onView(ViewMatchers.withId(R.id.login_username)) + .perform( + ViewActions.replaceText(getTestUsername()), + ViewActions.closeSoftKeyboard(), + ) + sleep(2000) + onView(ViewMatchers.withId(R.id.login_password)) + .perform( + ViewActions.replaceText(getTestUserPassword()), + ViewActions.closeSoftKeyboard(), + ) + sleep(2000) + onView(ViewMatchers.withId(R.id.login_button)) + .perform(ViewActions.click()) + sleep(10000) + } catch (ignored: NoMatchingViewException) { + } + } + + fun logoutUser() { + try { + onView( + Matchers.allOf( + ViewMatchers.withContentDescription("More"), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 4, + ), + ViewMatchers.isDisplayed(), + ), + ).perform(ViewActions.click()) + onView( + Matchers.allOf( + ViewMatchers.withId(R.id.more_logout), + ViewMatchers.withText("Logout"), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), + 0, + ), + 6, + ), + ), + ).perform(ViewActions.scrollTo(), ViewActions.click()) + onView( + Matchers.allOf( + ViewMatchers.withId(android.R.id.button1), + ViewMatchers.withText("Yes"), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.buttonPanel), + 0, + ), + 3, + ), + ), + ).perform(ViewActions.scrollTo(), ViewActions.click()) + sleep(5000) + } catch (ignored: NoMatchingViewException) { + } + } + + fun childAtPosition( + parentMatcher: Matcher, + position: Int, + ): Matcher { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("Child at position $position in parent ") + parentMatcher.describeTo(description) + } + + public override fun matchesSafely(view: View): Boolean { + val parent = view.parent + return parent is ViewGroup && + parentMatcher.matches(parent) && + view == parent.getChildAt(position) + } + } + } + + fun sleep(timeInMillis: Long) { + try { + Timber.d("Sleeping for %d", timeInMillis) + Thread.sleep(timeInMillis) + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + + private fun getTestUsername(): String { + val username = BuildConfig.TEST_USERNAME + if (StringUtils.isEmpty(username) || username == "null") { + throw NotImplementedError("Configure your beta account's username") + } else { + return username + } + } + + private fun getTestUserPassword(): String { + val password = BuildConfig.TEST_PASSWORD + if (StringUtils.isEmpty(password) || password == "null") { + throw NotImplementedError("Configure your beta account's password") + } else { + return password + } + } + + fun changeOrientation(activityRule: ActivityTestRule) { + activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + } + + fun first(matcher: Matcher): Matcher? { + return object : BaseMatcher() { + var isFirst = true + + override fun matches(item: Any): Boolean { + if (isFirst && matcher.matches(item)) { + isFirst = false + return true + } + return false + } + + override fun describeTo(description: Description) { + description.appendText("should return first matching item") + } + } + } + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt new file mode 100644 index 000000000..d3a814f2d --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import fr.free.nrw.commons.upload.UploadActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UploadActivityTest { + @get:Rule + var activityRule = ActivityTestRule(UploadActivity::class.java) + + @Test + fun orientationChange() { + UITestHelper.changeOrientation(activityRule) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt new file mode 100644 index 000000000..ed57709fc --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt @@ -0,0 +1,203 @@ +package fr.free.nrw.commons + +import android.app.Activity +import android.app.Instrumentation +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import fr.free.nrw.commons.locationpicker.LocationPickerActivity +import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition +import fr.free.nrw.commons.auth.LoginActivity +import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UploadCancelledTest { + @Rule + @JvmField + var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) + + @Rule + @JvmField + var mGrantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + "android.permission.WRITE_EXTERNAL_STORAGE", + ) + + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + try { + Intents.init() + } catch (_: IllegalStateException) { + } + device.unfreezeRotation() + device.setOrientationNatural() + device.freezeRotation() + UITestHelper.loginUser() + UITestHelper.skipWelcome() + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + } + + @After + fun teardown() { + try { + Intents.release() + } catch (_: IllegalStateException) { + } + } + + @Test + fun uploadCancelledAfterLocationPickedTest() { + val bottomNavigationItemView = + onView( + allOf( + childAtPosition( + childAtPosition( + withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 1, + ), + isDisplayed(), + ), + ) + bottomNavigationItemView.perform(click()) + + UITestHelper.sleep(12000) + + val actionMenuItemView = + onView( + allOf( + withId(R.id.list_sheet), + childAtPosition( + childAtPosition( + withId(R.id.toolbar), + 1, + ), + 0, + ), + isDisplayed(), + ), + ) + actionMenuItemView.perform(click()) + + val recyclerView = + onView( + allOf( + withId(R.id.rv_nearby_list), + ), + ) + recyclerView.perform( + RecyclerViewActions.actionOnItemAtPosition( + 0, + click(), + ), + ) + + val linearLayout3 = + onView( + allOf( + withId(R.id.cameraButton), + childAtPosition( + allOf( + withId(R.id.nearby_button_layout), + ), + 0, + ), + isDisplayed(), + ), + ) + linearLayout3.perform(click()) + + val pasteSensitiveTextInputEditText = + onView( + allOf( + withId(R.id.caption_item_edit_text), + childAtPosition( + childAtPosition( + withId(R.id.caption_item_edit_text_input_layout), + 0, + ), + 0, + ), + isDisplayed(), + ), + ) + pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) + + val pasteSensitiveTextInputEditText2 = + onView( + allOf( + withId(R.id.description_item_edit_text), + childAtPosition( + childAtPosition( + withId(R.id.description_item_edit_text_input_layout), + 0, + ), + 0, + ), + isDisplayed(), + ), + ) + pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) + + val appCompatButton2 = + onView( + allOf( + withId(R.id.btn_next), + childAtPosition( + childAtPosition( + withId(R.id.ll_container_media_detail), + 2, + ), + 1, + ), + isDisplayed(), + ), + ) + appCompatButton2.perform(click()) + + val appCompatButton3 = + onView( + allOf( + withId(android.R.id.button1), + ), + ) + appCompatButton3.perform(scrollTo(), click()) + + Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) + + val floatingActionButton3 = + onView( + allOf( + withId(R.id.location_chosen_button), + isDisplayed(), + ), + ) + UITestHelper.sleep(2000) + floatingActionButton3.perform(click()) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt new file mode 100644 index 000000000..048d540b7 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt @@ -0,0 +1,363 @@ +package fr.free.nrw.commons + +import android.Manifest +import android.app.Activity +import android.app.Instrumentation.ActivityResult +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Environment +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.upload.UploadMediaDetailAdapter +import fr.free.nrw.commons.util.MyViewAction +import fr.free.nrw.commons.utils.ConfigUtils +import org.hamcrest.core.AllOf.allOf +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Random + +@LargeTest +@RunWith(AndroidJUnit4::class) +class UploadTest { + @get:Rule + var permissionRule = + GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.ACCESS_FINE_LOCATION, + )!! + + @get:Rule + var activityRule = ActivityTestRule(LoginActivity::class.java) + + private val randomBitmap: Bitmap + get() { + val random = Random() + val bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(random.nextInt(255)) + return bitmap + } + + @Before + fun setup() { + try { + Intents.init() + } catch (_: IllegalStateException) { + } + UITestHelper.loginUser() + UITestHelper.skipWelcome() + } + + @After + fun teardown() { + Intents.release() + } + + @Test + @Ignore("Fix Failing Test") + fun testUploadWithDescription() { + if (!ConfigUtils.isBetaFlavour) { + throw Error("This test should only be run in Beta!") + } + + setupSingleUpload("image.jpg") + + openGallery() + + // Validate that an intent to get an image is sent + intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*"))) + + // Create filename with the current time (to prevent overwrites) + val dateFormat = SimpleDateFormat("yyMMdd-hhmmss") + val commonsFileName = "MobileTest " + dateFormat.format(Date()) + + // Try to dismiss the error, if there is one (probably about duplicate files on Commons) + dismissWarning("Yes") + + onView(allOf(isDisplayed(), withId(R.id.tv_title))) + .perform(replaceText(commonsFileName)) + + onView(allOf(isDisplayed(), withId(R.id.description_item_edit_text))) + .perform(replaceText(commonsFileName)) + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + UITestHelper.sleep(5000) + dismissWarning("Yes") + + UITestHelper.sleep(3000) + + onView(allOf(isDisplayed(), withId(R.id.et_search))) + .perform(replaceText("Uploaded with Mobile/Android Tests")) + + UITestHelper.sleep(3000) + + try { + onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) + .perform(click()) + } catch (ignored: NoMatchingViewException) { + } + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + dismissWarning("Yes, Submit") + + UITestHelper.sleep(500) + + onView(allOf(isDisplayed(), withId(R.id.btn_submit))) + .perform(click()) + + UITestHelper.sleep(10000) + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + commonsFileName.replace(' ', '_') + ".jpg" + Timber.i("File should be uploaded to $fileUrl") + } + + private fun dismissWarning(warningText: String) { + try { + onView(withText(warningText)) + .check(matches(isDisplayed())) + .perform(click()) + } catch (ignored: NoMatchingViewException) { + } + } + + @Test + @Ignore("Fix Failing Test") + fun testUploadWithoutDescription() { + if (!ConfigUtils.isBetaFlavour) { + throw Error("This test should only be run in Beta!") + } + + setupSingleUpload("image.jpg") + + openGallery() + + // Validate that an intent to get an image is sent + intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*"))) + + // Create filename with the current time (to prevent overwrites) + val dateFormat = SimpleDateFormat("yyMMdd-hhmmss") + val commonsFileName = "MobileTest " + dateFormat.format(Date()) + + // Try to dismiss the error, if there is one (probably about duplicate files on Commons) + dismissWarning("Yes") + + onView(allOf(isDisplayed(), withId(R.id.tv_title))) + .perform(replaceText(commonsFileName)) + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + UITestHelper.sleep(10000) + dismissWarning("Yes") + + UITestHelper.sleep(3000) + + onView(allOf(isDisplayed(), withId(R.id.et_search))) + .perform(replaceText("Test")) + + UITestHelper.sleep(3000) + + try { + onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) + .perform(click()) + } catch (ignored: NoMatchingViewException) { + } + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + dismissWarning("Yes, Submit") + + UITestHelper.sleep(500) + + onView(allOf(isDisplayed(), withId(R.id.btn_submit))) + .perform(click()) + + UITestHelper.sleep(10000) + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + commonsFileName.replace(' ', '_') + ".jpg" + Timber.i("File should be uploaded to $fileUrl") + } + + @Test + @Ignore("Fix Failing Test") + fun testUploadWithMultilingualDescription() { + if (!ConfigUtils.isBetaFlavour) { + throw Error("This test should only be run in Beta!") + } + + setupSingleUpload("image.jpg") + + openGallery() + + // Validate that an intent to get an image is sent + intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*"))) + + // Create filename with the current time (to prevent overwrites) + val dateFormat = SimpleDateFormat("yyMMdd-hhmmss") + val commonsFileName = "MobileTest " + dateFormat.format(Date()) + + // Try to dismiss the error, if there is one (probably about duplicate files on Commons) + dismissWarningDialog() + + onView(allOf(isDisplayed(), withId(R.id.tv_title))) + .perform(replaceText(commonsFileName)) + + onView(withId(R.id.rv_descriptions)).perform( + RecyclerViewActions + .actionOnItemAtPosition( + 0, + MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"), + ), + ) + + onView(withId(R.id.btn_add)) + .perform(click()) + + onView(withId(R.id.rv_descriptions)).perform( + RecyclerViewActions + .actionOnItemAtPosition( + 1, + MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"), + ), + ) + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + UITestHelper.sleep(5000) + dismissWarning("Yes") + + UITestHelper.sleep(3000) + + onView(allOf(isDisplayed(), withId(R.id.et_search))) + .perform(replaceText("Test")) + + UITestHelper.sleep(3000) + + try { + onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) + .perform(click()) + } catch (ignored: NoMatchingViewException) { + } + + onView(allOf(isDisplayed(), withId(R.id.btn_next))) + .perform(click()) + + dismissWarning("Yes, Submit") + + UITestHelper.sleep(500) + + onView(allOf(isDisplayed(), withId(R.id.btn_submit))) + .perform(click()) + + UITestHelper.sleep(10000) + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + commonsFileName.replace(' ', '_') + ".jpg" + Timber.i("File should be uploaded to $fileUrl") + } + + private fun setupSingleUpload(imageName: String) { + saveToInternalStorage(imageName) + singleImageIntent(imageName) + } + + private fun saveToInternalStorage(imageName: String) { + val bitmapImage = randomBitmap + + // path to /data/data/yourapp/app_data/imageDir + val mypath = File(Environment.getExternalStorageDirectory(), imageName) + + Timber.d("Filepath: %s", mypath.path) + + Timber.d("Absolute Filepath: %s", mypath.absolutePath) + + var fos: FileOutputStream? = null + try { + fos = FileOutputStream(mypath) + // Use the compress method on the BitMap object to write image to the OutputStream + bitmapImage.compress(Bitmap.CompressFormat.JPEG, 100, fos) + } catch (e: Exception) { + e.printStackTrace() + } finally { + try { + fos?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + } + + private fun singleImageIntent(imageName: String) { + // Uri to return by our mock gallery selector + // Requires file 'image.jpg' to be placed at root of file structure + val imageUri = Uri.parse("file://mnt/sdcard/$imageName") + + // Build a result to return from the Camera app + val intent = Intent() + intent.data = imageUri + val result = ActivityResult(Activity.RESULT_OK, intent) + + // Stub out the File picker. When an intent is sent to the File picker, this tells + // Espresso to respond with the ActivityResult we just created + intending(allOf(hasAction(Intent.ACTION_GET_CONTENT), hasType("image/*"))).respondWith(result) + } + + private fun dismissWarningDialog() { + try { + onView(withText("Yes")) + .check(matches(isDisplayed())) + .perform(click()) + } catch (ignored: NoMatchingViewException) { + } + } + + private fun openGallery() { + // Open FAB + onView(allOf(withId(R.id.fab_plus), isDisplayed())) + .perform(click()) + + // Click gallery + onView(allOf(withId(R.id.fab_gallery), isDisplayed())) + .perform(click()) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt new file mode 100644 index 000000000..5956b3c02 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt @@ -0,0 +1,133 @@ +package fr.free.nrw.commons + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.viewpager.widget.ViewPager +import fr.free.nrw.commons.utils.ConfigUtils +import org.hamcrest.core.IsNot.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.CoreMatchers.equalTo + +@LargeTest +@RunWith(AndroidJUnit4::class) +class WelcomeActivityTest { + @get:Rule + var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + @Before + fun setup() { + device.setOrientationNatural() + device.freezeRotation() + } + + @Test + fun ifBetaShowsSkipButton() { + if (ConfigUtils.isBetaFlavour) { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.finishTutorialButton)) + .check(matches(isDisplayed())) + } + } + + @Test + fun ifProdHidesSkipButton() { + if (!ConfigUtils.isBetaFlavour) { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.finishTutorialButton)) + .check(matches(not(isDisplayed()))) + } + } + + @Test + fun testBetaSkipButton() { + if (ConfigUtils.isBetaFlavour) { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.finishTutorialButton)) + .perform(ViewActions.click()) + assertThat(activityRule.activity.isDestroyed, equalTo(true)) + } + } + + @Test + fun testSwipingOnce() { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeLeft()) + assertThat(true, equalTo(true)) + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeRight()) + assertThat(true, equalTo(true)) + } + + @Test + fun testSwipingWholeTutorial() { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeLeft()) + .perform(ViewActions.swipeLeft()) + .perform(ViewActions.swipeLeft()) + .perform(ViewActions.swipeLeft()) + assertThat(true, equalTo(true)) + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeRight()) + .perform(ViewActions.swipeRight()) + .perform(ViewActions.swipeRight()) + .perform(ViewActions.swipeRight()) + assertThat(true, equalTo(true)) + } + + @Test + fun swipeBeyondBounds() { + val viewPager = activityRule.activity.findViewById(R.id.welcomePager) + + viewPager.adapter?.let { + if (viewPager.currentItem == 3) { + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeLeft()) + assertThat(true, equalTo(true)) + onView(withId(R.id.welcomePager)) + .perform(ViewActions.swipeRight()) + assertThat(true, equalTo(true)) + } + } + } + + @Test + fun swipeTillLastAndFinish() { + val viewPager = activityRule.activity.findViewById(R.id.welcomePager) + + viewPager.adapter?.let { + if (viewPager.currentItem == 3) { + onView(withId(R.id.button_ok)) + .perform(ViewActions.click()) + onView(withId(R.id.finishTutorialButton)) + .perform(ViewActions.click()) + assertThat(activityRule.activity.isDestroyed, equalTo(true)) + } + } + } + + @Test + fun orientationChange() { + UITestHelper.changeOrientation(activityRule) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt b/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt new file mode 100644 index 000000000..54228bc13 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt @@ -0,0 +1,271 @@ +package fr.free.nrw.commons.contributions + +import android.content.res.Configuration +import android.os.Looper +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.floatingactionbutton.FloatingActionButton +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.OkHttpConnectionFactory +import fr.free.nrw.commons.R +import fr.free.nrw.commons.TestCommonsApplication +import fr.free.nrw.commons.createTestClient +import fr.free.nrw.commons.upload.WikidataPlace +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.lang.reflect.Method + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [21], application = TestCommonsApplication::class) +@LooperMode(LooperMode.Mode.PAUSED) +class ContributionsListFragmentUnitTests { + private lateinit var scenario: FragmentScenario + private lateinit var fragment: ContributionsListFragment + + private val adapter: ContributionsListAdapter = mock() + private val contribution: Contribution = mock() + private val media: Media = mock() + private val wikidataPlace: WikidataPlace = mock() + + @Before + fun setUp() { + OkHttpConnectionFactory.CLIENT = createTestClient() + + scenario = + launchFragmentInContainer( + initialState = Lifecycle.State.RESUMED, + themeResId = R.style.LightAppTheme, + ) { + ContributionsListFragment() + .apply { + contributionsListPresenter = mock() + callback = mock() + }.also { + fragment = it + } + } + + scenario.onFragment { + it.adapter = adapter + } + } + + @Test + @Throws(Exception::class) + fun checkFragmentNotNull() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + Assert.assertNotNull(fragment) + } + + @Test + @Throws(Exception::class) + fun testOnDetach() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.onDetach() + } + + @Test + @Throws(Exception::class) + fun testGetContributionStateAt() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + `when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution) + fragment.getContributionStateAt(0) + } + + @Test + @Throws(Exception::class) + fun testOnScrollToTop() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.rvContributionsList = mock() + fragment.scrollToTop() + verify(fragment.rvContributionsList)?.smoothScrollToPosition(0) + } + + @Test + @Throws(Exception::class) + fun testOnConfirmClicked() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + `when`(contribution.media).thenReturn(media) + `when`(media.wikiCode).thenReturn("") + `when`(contribution.wikidataPlace).thenReturn(wikidataPlace) + fragment.onConfirmClicked(contribution, true) + } + + @Test + @Throws(Exception::class) + fun testGetTotalMediaCount() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.totalMediaCount + } + + @Test + @Throws(Exception::class) + fun testGetMediaAtPositionCaseNonNull() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + `when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution) + `when`(contribution.media).thenReturn(media) + fragment.getMediaAtPosition(0) + } + + @Test + @Throws(Exception::class) + fun testGetMediaAtPositionCaseNull() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + `when`(adapter.getContributionForPosition(anyInt())).thenReturn(null) + fragment.getMediaAtPosition(0) + } + + @Test + @Throws(Exception::class) + fun testShowAddImageToWikipediaInstructions() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "showAddImageToWikipediaInstructions", + Contribution::class.java, + ) + method.isAccessible = true + method.invoke(fragment, contribution) + } + + @Test + @Throws(Exception::class) + fun testAddImageToWikipedia() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.addImageToWikipedia(contribution) + } + + @Test + @Throws(Exception::class) + fun testOpenMediaDetail() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.openMediaDetail(0, true) + } + + @Test + @Throws(Exception::class) + fun testOnViewStateRestored() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.onViewStateRestored(mock()) + } + + @Test + @Throws(Exception::class) + fun testOnSaveInstanceState() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.onSaveInstanceState(mock()) + } + + @Test + @Throws(Exception::class) + fun testShowNoContributionsUI() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.showNoContributionsUI(true) + } + + @Test + @Throws(Exception::class) + fun testShowProgress() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.showProgress(true) + } + + @Test + @Throws(Exception::class) + fun testShowWelcomeTip() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + fragment.showWelcomeTip(true) + } + + @Test + @Throws(Exception::class) + fun testAnimateFAB() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { + it.requireView().findViewById(R.id.fab_plus).hide() + } + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "animateFAB", + Boolean::class.java, + ) + method.isAccessible = true + method.invoke(fragment, true) + } + + @Test + @Throws(Exception::class) + fun testAnimateFABCaseShownAndOpen() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { + it.requireView().findViewById(R.id.fab_plus).show() + } + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "animateFAB", + Boolean::class.java, + ) + method.isAccessible = true + method.invoke(fragment, true) + } + + @Test + @Throws(Exception::class) + fun testAnimateFABCaseShownAndClose() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { + it.requireView().findViewById(R.id.fab_plus).show() + } + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "animateFAB", + Boolean::class.java, + ) + method.isAccessible = true + method.invoke(fragment, false) + } + + @Test + @Throws(Exception::class) + fun testSetListeners() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "setListeners", + ) + method.isAccessible = true + method.invoke(fragment) + } + + @Test + @Throws(Exception::class) + fun testInitializeAnimations() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + val method: Method = + ContributionsListFragment::class.java.getDeclaredMethod( + "initializeAnimations", + ) + method.isAccessible = true + method.invoke(fragment) + } + + @Test + @Throws(Exception::class) + fun testOnConfigurationChanged() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + val newConfig: Configuration = mock() + newConfig.orientation = Configuration.ORIENTATION_LANDSCAPE + fragment.onConfigurationChanged(newConfig) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt b/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt new file mode 100644 index 000000000..c2906b501 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragmentUnitTests.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.navtab + +import android.os.Looper +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import fr.free.nrw.commons.R +import fr.free.nrw.commons.TestCommonsApplication +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [21], application = TestCommonsApplication::class) +@LooperMode(LooperMode.Mode.PAUSED) +class MoreBottomSheetLoggedOutFragmentUnitTests { + private lateinit var scenario: FragmentScenario + + @Before + fun setUp() { + scenario = + launchFragmentInContainer( + initialState = Lifecycle.State.RESUMED, + themeResId = R.style.LightAppTheme, + ) { + MoreBottomSheetLoggedOutFragment() + } + } + + @Test + @Throws(Exception::class) + fun testOnSettingsClicked() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { it.onSettingsClicked() } + } + + @Test + @Throws(Exception::class) + fun testOnAboutClicked() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { it.onAboutClicked() } + } + + @Test + @Throws(Exception::class) + fun testOnFeedbackClicked() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { it.onFeedbackClicked() } + } + + @Test + @Throws(Exception::class) + fun testOnLogoutClicked() { + Shadows.shadowOf(Looper.getMainLooper()).idle() + scenario.onFragment { it.onLogoutClicked() } + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt new file mode 100644 index 000000000..647c5bbda --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PasteSensitiveTextInputEditTextTest { + private var context: Context? = null + private var textView: PasteSensitiveTextInputEditText? = null + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + textView = PasteSensitiveTextInputEditText(context!!) + } + + // this test has no real value, just % for test code coverage + @Test + fun extractFormattingAttributeSet() { + val methodExtractFormattingAttribute = + textView!!.javaClass.getDeclaredMethod( + "extractFormattingAttribute", + Context::class.java, + AttributeSet::class.java, + ) + methodExtractFormattingAttribute.isAccessible = true + methodExtractFormattingAttribute.invoke(textView, context, null) + } + + @Test + @Throws(Exception::class) + fun setFormattingAllowed() { + val fieldFormattingAllowed = textView!!.javaClass.getDeclaredField("formattingAllowed") + fieldFormattingAllowed.isAccessible = true + textView!!.setFormattingAllowed(true) + Assert.assertTrue(fieldFormattingAllowed.getBoolean(textView)) + textView!!.setFormattingAllowed(false) + Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView)) + } +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt new file mode 100644 index 000000000..52ac18e4d --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt @@ -0,0 +1,66 @@ +package fr.free.nrw.commons.util + +import android.view.View +import android.widget.EditText +import androidx.appcompat.widget.AppCompatSpinner +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import org.hamcrest.Matcher + +class MyViewAction { + companion object { + fun typeTextInChildViewWithId( + id: Int, + textToBeTyped: String, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null + + override fun getDescription(): String = "Click on a child view with specified id." + + override fun perform( + uiController: UiController, + view: View, + ) { + val v = view.findViewById(id) as EditText + v.setText(textToBeTyped) + } + } + + fun selectSpinnerItemInChildViewWithId( + id: Int, + position: Int, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null + + override fun getDescription(): String = "Click on a child view with specified id." + + override fun perform( + uiController: UiController, + view: View, + ) { + val v = view.findViewById(id) as AppCompatSpinner + v.setSelection(position) + } + } + + fun clickItemWithId( + id: Int, + position: Int, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null + + override fun getDescription(): String = "Click on a child view with specified id." + + override fun perform( + uiController: UiController, + view: View, + ) { + val v = view.findViewById(id) as View + v.performClick() + } + } + } +} diff --git a/app/src/beta/res/values/adapter.xml b/app/src/beta/res/values/adapter.xml new file mode 100644 index 000000000..8e2257563 --- /dev/null +++ b/app/src/beta/res/values/adapter.xml @@ -0,0 +1,7 @@ + + + fr.free.nrw.commons.beta + fr.free.nrw.commons.beta.contributions.contentprovider + fr.free.nrw.commons.beta.modifications.contentprovider + fr.free.nrw.commons.beta.categories.contentprovider + diff --git a/app/src/beta/res/xml/shortcuts.xml b/app/src/beta/res/xml/shortcuts.xml new file mode 100644 index 000000000..65a51995e --- /dev/null +++ b/app/src/beta/res/xml/shortcuts.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/betaDebug/ic_launcher-web.png b/app/src/betaDebug/ic_launcher-web.png new file mode 100644 index 000000000..5b1546360 Binary files /dev/null and b/app/src/betaDebug/ic_launcher-web.png differ diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/app/src/betaDebug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..90c044ccd Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..f826d5544 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..9b273c43f Binary files /dev/null and b/app/src/betaDebug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b09b8d252 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..5002ec69d Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..9aa2611ba Binary files /dev/null and b/app/src/betaDebug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d7b349b4d Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..9297963fd Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..59b088069 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d473d0aed Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..aeb616311 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..0b7797049 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..e88874931 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..fa5017d72 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..00a9e4bd5 Binary files /dev/null and b/app/src/betaDebug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..17917666d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_explore-web.png b/app/src/main/ic_explore-web.png new file mode 100644 index 000000000..55718bec5 Binary files /dev/null and b/app/src/main/ic_explore-web.png differ diff --git a/app/src/main/ic_filled_star-web.png b/app/src/main/ic_filled_star-web.png new file mode 100644 index 000000000..653e8bbaa Binary files /dev/null and b/app/src/main/ic_filled_star-web.png differ diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 000000000..c7f0bc3fe Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/ic_settings_black-web.png b/app/src/main/ic_settings_black-web.png new file mode 100644 index 000000000..0b0e6758a Binary files /dev/null and b/app/src/main/ic_settings_black-web.png differ diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt new file mode 100644 index 000000000..865ad3ddb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt @@ -0,0 +1,207 @@ +package fr.free.nrw.commons + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.Spinner +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.databinding.ActivityAboutBinding +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import java.util.Collections +import androidx.core.net.toUri +import fr.free.nrw.commons.utils.applyEdgeToEdgeTopInsets +import fr.free.nrw.commons.utils.handleWebUrl +import fr.free.nrw.commons.utils.setUnderlinedText + +/** + * Represents about screen of this app + */ +class AboutActivity : BaseActivity() { + /* + This View Binding class is auto-generated for each xml file. The format is usually the name + of the file with PascalCasing (The underscore characters will be ignored). + More information is available at https://developer.android.com/topic/libraries/view-binding + */ + private var binding: ActivityAboutBinding? = null + + /** + * This method helps in the creation About screen + * + * @param savedInstanceState Data bundle + */ + @SuppressLint("StringFormatInvalid") //TODO: + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /* + Instead of just setting the view with the xml file. We need to use View Binding class. + */ + binding = ActivityAboutBinding.inflate(layoutInflater) + val view: View = binding!!.root + applyEdgeToEdgeTopInsets(binding!!.toolbarLayout) + setContentView(view) + + setSupportActionBar(binding!!.toolbarBinding.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + val aboutText = getString(R.string.about_license) + /* + We can then access all the views by just using the id names like this. + camelCasing is used with underscore characters being ignored. + */ + binding!!.aboutLicense.setHtmlText(aboutText) + + @SuppressLint("StringFormatMatches") // TODO: + val improveText = + String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL) + binding!!.aboutImprove.setHtmlText(improveText) + binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha() + + binding!!.aboutFaq.setUnderlinedText(R.string.about_faq) + binding!!.aboutRateUs.setUnderlinedText(R.string.about_rate_us) + binding!!.aboutUserGuide.setUnderlinedText(R.string.user_guide) + binding!!.aboutPrivacyPolicy.setUnderlinedText(R.string.about_privacy_policy) + binding!!.aboutTranslate.setUnderlinedText(R.string.about_translate) + binding!!.aboutCredits.setUnderlinedText(R.string.about_credits) + + /* + To set listeners, we can create a separate method and use lambda syntax. + */ + binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook) + binding!!.githubLaunchIcon.setOnClickListener(::launchGithub) + binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite) + binding!!.aboutRateUs.setOnClickListener(::launchRatings) + binding!!.aboutCredits.setOnClickListener(::launchCredits) + binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy) + binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide) + binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions) + binding!!.aboutTranslate.setOnClickListener(::launchTranslate) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + fun launchFacebook(view: View?) { + val intent: Intent + try { + intent = Intent(ACTION_VIEW, Urls.FACEBOOK_APP_URL.toUri()) + intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + handleWebUrl(this, Urls.FACEBOOK_WEB_URL.toUri()) + } + } + + fun launchGithub(view: View?) { + val intent: Intent + try { + intent = Intent(ACTION_VIEW, Urls.GITHUB_REPO_URL.toUri()) + intent.setPackage(Urls.GITHUB_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + handleWebUrl(this, Urls.GITHUB_REPO_URL.toUri()) + } + } + + fun launchWebsite(view: View?) { + handleWebUrl(this, Urls.WEBSITE_URL.toUri()) + } + + fun launchRatings(view: View?) { + try { + startActivity( + Intent( + ACTION_VIEW, + (Urls.PLAY_STORE_PREFIX + packageName).toUri() + ) + ) + } catch (_: ActivityNotFoundException) { + handleWebUrl(this, (Urls.PLAY_STORE_URL_PREFIX + packageName).toUri()) + } + } + + fun launchCredits(view: View?) { + handleWebUrl(this, Urls.CREDITS_URL.toUri()) + } + + fun launchUserGuide(view: View?) { + handleWebUrl(this, Urls.USER_GUIDE_URL.toUri()) + } + + fun launchPrivacyPolicy(view: View?) { + handleWebUrl(this, BuildConfig.PRIVACY_POLICY_URL.toUri()) + } + + fun launchFrequentlyAskedQuesions(view: View?) { + handleWebUrl(this, Urls.FAQ_URL.toUri()) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_about, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.share_app_icon -> { + val shareText = String.format( + getString(R.string.share_text), + Urls.PLAY_STORE_URL_PREFIX + this.packageName + ) + val sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.putExtra(Intent.EXTRA_TEXT, shareText) + sendIntent.setType("text/plain") + startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via))) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun launchTranslate(view: View?) { + val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames() + Collections.sort(sortedLocalizedNamesRef) + val languageAdapter = ArrayAdapter( + this@AboutActivity, + android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef + ) + val spinner = Spinner(this@AboutActivity) + spinner.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + spinner.setPadding(50, 0, 0, 0) + + val positiveButtonRunnable = Runnable { + val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition] + handleWebUrl(this@AboutActivity, (Urls.TRANSLATE_WIKI_URL + langCode).toUri()) + } + showAlertDialog( + this, + getString(R.string.about_translate_title), + getString(R.string.about_translate_message), + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + positiveButtonRunnable, + {}, + spinner + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt new file mode 100644 index 000000000..28b01d603 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt @@ -0,0 +1,63 @@ +package fr.free.nrw.commons + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place + +class BaseMarker { + private var _position: LatLng = LatLng(0.0, 0.0, 0f) + private var _title: String = "" + private var _place: Place = Place() + private var _icon: Bitmap? = null + + var position: LatLng + get() = _position + set(value) { + _position = value + } + var title: String + get() = _title + set(value) { + _title = value + } + + var place: Place + get() = _place + set(value) { + _place = value + } + var icon: Bitmap? + get() = _icon + set(value) { + _icon = value + } + + constructor() { + } + + fun fromResource( + context: Context, + drawableResId: Int, + ) { + val drawable: Drawable = context.resources.getDrawable(drawableResId) + icon = + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + val bitmap = + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888, + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt b/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt new file mode 100644 index 000000000..085307c3e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/BasePresenter.kt @@ -0,0 +1,10 @@ +package fr.free.nrw.commons + +/** + * Base presenter, enforcing contracts to attach and detach view + */ +interface BasePresenter { + fun onAttachView(view: T) + + fun onDetachView() +} diff --git a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt new file mode 100644 index 000000000..c0c0b9a61 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons + +/** + * Production variant related constants which is used in beta variant for some specific GET calls on + * production server where beta server does not work + */ +object BetaConstants { + /** + * Commons production URL which is used in beta for some specific GET calls on + * production server where beta server does not work + */ + const val COMMONS_URL = "https://commons.wikimedia.org/" + + /** + * Commons production's depicts property which is used in beta for some specific GET calls on + * production server where beta server does not work + */ + const val DEPICTS_PROPERTY = "P180" +} diff --git a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt new file mode 100644 index 000000000..e3a644c6a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons + +import android.os.Parcel +import android.os.Parcelable + +class CameraPosition( + val latitude: Double, + val longitude: Double, + val zoom: Double, +) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readDouble(), + parcel.readDouble(), + parcel.readDouble(), + ) + + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { + parcel.writeDouble(latitude) + parcel.writeDouble(longitude) + parcel.writeDouble(zoom) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt new file mode 100644 index 000000000..89fdaa055 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -0,0 +1,417 @@ +package fr.free.nrw.commons + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.database.sqlite.SQLiteException +import android.os.Build +import android.os.Process +import android.util.Log +import androidx.multidex.MultiDexApplication +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.core.ImagePipelineConfig +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable +import fr.free.nrw.commons.category.CategoryDao +import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler +import fr.free.nrw.commons.concurrency.ThreadPoolService +import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.data.DBOpenHelper +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.language.AppLanguageLookUpTable +import fr.free.nrw.commons.logging.FileLoggingTree +import fr.free.nrw.commons.logging.LogUtils +import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher +import fr.free.nrw.commons.settings.Prefs +import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar +import io.reactivex.Completable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.internal.functions.Functions +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers +import org.acra.ACRA.init +import org.acra.ReportField +import org.acra.annotation.AcraCore +import org.acra.annotation.AcraDialog +import org.acra.annotation.AcraMailSender +import org.acra.data.StringFormat +import timber.log.Timber +import timber.log.Timber.DebugTree +import java.io.File +import javax.inject.Inject +import javax.inject.Named + +@AcraCore( + buildConfigClass = BuildConfig::class, + resReportSendSuccessToast = R.string.crash_dialog_ok_toast, + reportFormat = StringFormat.KEY_VALUE_LIST, + reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE] +) + +@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false) + +@AcraDialog( + resTheme = R.style.Theme_AppCompat_Dialog, + resText = R.string.crash_dialog_text, + resTitle = R.string.crash_dialog_title, + resCommentPrompt = R.string.crash_dialog_comment_prompt +) + +class CommonsApplication : MultiDexApplication() { + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var dbOpenHelper: DBOpenHelper + + @Inject + @field:Named("default_preferences") + lateinit var defaultPrefs: JsonKvStore + + @Inject + lateinit var cookieJar: CommonsCookieJar + + @Inject + lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher + + var languageLookUpTable: AppLanguageLookUpTable? = null + private set + + @Inject + lateinit var contributionDao: ContributionDao + + /** + * Used to declare and initialize various components and dependencies + */ + override fun onCreate() { + super.onCreate() + + instance = this + init(this) + + ApplicationlessInjection + .getInstance(this) + .commonsApplicationComponent + .inject(this) + + initTimber() + + if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) { + var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS) + if (null == defaultExifTagsSet) { + defaultExifTagsSet = HashSet() + } + defaultExifTagsSet.add(getString(R.string.exif_tag_location)) + defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet) + } + + // Set DownsampleEnabled to True to downsample the image in case it's heavy + val config = ImagePipelineConfig.newBuilder(this) + .setNetworkFetcher(customOkHttpNetworkFetcher) + .setDownsampleEnabled(true) + .build() + try { + Fresco.initialize(this, config) + } catch (e: Exception) { + Timber.e(e) + // TODO: Remove when we're able to initialize Fresco in test builds. + } + + createNotificationChannel(this) + + languageLookUpTable = AppLanguageLookUpTable(this) + + // This handler will catch exceptions thrown from Observables after they are disposed, + // or from Observables that are (deliberately or not) missing an onError handler. + RxJavaPlugins.setErrorHandler(Functions.emptyConsumer()) + + // Fire progress callbacks for every 3% of uploaded content + System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0") + } + + /** + * Plants debug and file logging tree. Timber lets you plant your own logging trees. + */ + private fun initTimber() { + val isBeta = isBetaFlavour + val logFileName = + if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs" + val logDirectory = LogUtils.getLogDirectory() + //Delete stale logs if they have exceeded the specified size + deleteStaleLogs(logFileName, logDirectory) + + val tree = FileLoggingTree( + Log.VERBOSE, + logFileName, + logDirectory, + 1000, + fileLoggingThreadPool + ) + + Timber.plant(tree) + Timber.plant(DebugTree()) + } + + /** + * Deletes the logs zip file at the specified directory and file locations specified in the + * params + * + * @param logFileName + * @param logDirectory + */ + private fun deleteStaleLogs(logFileName: String, logDirectory: String) { + try { + val file = File("$logDirectory/zip/$logFileName.zip") + if (file.exists() && file.totalSpace > 1000000) { // In Kbs + file.delete() + } + } catch (e: Exception) { + Timber.e(e) + } + } + + private val fileLoggingThreadPool: ThreadPoolService + get() = ThreadPoolService.Builder("file-logging-thread") + .setPriority(Process.THREAD_PRIORITY_LOWEST) + .setPoolSize(1) + .setExceptionHandler(BackgroundPoolExceptionHandler()) + .build() + + val userAgent: String + get() = ("Commons/" + this.getVersionNameWithSha() + + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE) + + /** + * clears data of current application + * + * @param context Application context + * @param logoutListener Implementation of interface LogoutListener + */ + @SuppressLint("CheckResult") + fun clearApplicationData(context: Context, logoutListener: LogoutListener) { + val cacheDirectory = context.cacheDir + val applicationDirectory = File(cacheDirectory.parent) + if (applicationDirectory.exists()) { + val fileNames = applicationDirectory.list() + for (fileName in fileNames) { + if (fileName != "lib") { + FileUtils.deleteFile(File(applicationDirectory, fileName)) + } + } + } + + sessionManager.logout() + .andThen(Completable.fromAction { cookieJar.clear() }) + .andThen(Completable.fromAction { + Timber.d("All accounts have been removed") + clearImageCache() + //TODO: fix preference manager + defaultPrefs.clearAll() + defaultPrefs.putBoolean("firstrun", false) + updateAllDatabases() + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) }) + } + + /** + * Clear all images cache held by Fresco + */ + private fun clearImageCache() { + val imagePipeline = Fresco.getImagePipeline() + imagePipeline.clearCaches() + } + + /** + * Deletes all tables and re-creates them. + */ + private fun updateAllDatabases() { + dbOpenHelper.readableDatabase.close() + val db = dbOpenHelper.writableDatabase + + CategoryDao.Table.onDelete(db) + dbOpenHelper.deleteTable( + db, + DBOpenHelper.CONTRIBUTIONS_TABLE + ) //Delete the contributions table in the existing db on older versions + + dbOpenHelper.deleteTable( + db, + DBOpenHelper.BOOKMARKS_LOCATIONS + ) + + try { + contributionDao.deleteAll() + } catch (e: SQLiteException) { + Timber.e(e) + } + BookmarksTable.onDelete(db) + BookmarkItemsTable.onDelete(db) + } + + + /** + * Interface used to get log-out events + */ + interface LogoutListener { + fun onLogoutComplete() + } + + /** + * This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity + * with relevant intent parameters. It does not perform the actual logout operation. + */ + open class BaseLogoutListener : LogoutListener { + var ctx: Context + var loginMessage: String? = null + var userName: String? = null + + /** + * Constructor for BaseLogoutListener. + * + * @param ctx Application context + */ + constructor(ctx: Context) { + this.ctx = ctx + } + + /** + * Constructor for BaseLogoutListener + * + * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process. + * @param loginMessage Message to be displayed on the login page + * @param loginUsername Username to be pre-filled on the login page + */ + constructor( + ctx: Context, loginMessage: String?, + loginUsername: String? + ) { + this.ctx = ctx + this.loginMessage = loginMessage + this.userName = loginUsername + } + + override fun onLogoutComplete() { + Timber.d("Logout complete callback received.") + val loginIntent = Intent(ctx, LoginActivity::class.java) + loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + if (loginMessage != null) { + loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage) + } + if (userName != null) { + loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName) + } + + ctx.startActivity(loginIntent) + } + } + + /** + * This class is an extension of BaseLogoutListener, providing additional functionality or customization + * for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen. + */ + class ActivityLogoutListener : BaseLogoutListener { + var activity: Activity + + + /** + * Constructor for ActivityLogoutListener. + * + * @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity. + * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process. + */ + constructor(activity: Activity, ctx: Context) : super(ctx) { + this.activity = activity + } + + /** + * Constructor for ActivityLogoutListener with additional parameters for the login screen. + * + * @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity. + * @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process. + * @param loginMessage Message to be displayed on the login page after logout. + * @param loginUsername Username to be pre-filled on the login page after logout. + */ + constructor( + activity: Activity, ctx: Context?, + loginMessage: String?, loginUsername: String? + ) : super(activity, loginMessage, loginUsername) { + this.activity = activity + } + + override fun onLogoutComplete() { + super.onLogoutComplete() + activity.finish() + } + } + + companion object { + + const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage" + const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername" + + const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled" + + /** + * Constants begin + */ + const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001 + + const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]" + + const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com" + + const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback" + + const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com" + + const val REPORT_EMAIL_SUBJECT: String = "Report a violation" + + const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll" + + const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --" + + /** + * Constants End + */ + + @JvmStatic + lateinit var instance: CommonsApplication + private set + + @JvmField + var isPaused: Boolean = false + + @JvmStatic + fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context + .getSystemService(NOTIFICATION_SERVICE) as NotificationManager + var channel = manager + .getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL) + if (channel == null) { + channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID_ALL, + context.getString(R.string.notifications_channel_name_all), + NotificationManager.IMPORTANCE_DEFAULT + ) + manager.createNotificationChannel(channel) + } + } + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/MapController.kt b/app/src/main/java/fr/free/nrw/commons/MapController.kt new file mode 100644 index 000000000..5888b3f5f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/MapController.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons + +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place + +abstract class MapController { + /** + * We pass this variable as a group of placeList and boundaryCoordinates + */ + inner class NearbyPlacesInfo { + @JvmField + var placeList: List = emptyList() // List of nearby places + + @JvmField + var boundaryCoordinates: Array = emptyArray() // Corners of nearby area + + @JvmField + var currentLatLng: LatLng? = null // Current location when this places are populated + + @JvmField + var searchLatLng: LatLng? = null // Search location for finding this places + + @JvmField + var mediaList: List? = null // Search location for finding this places + } + + /** + * We pass this variable as a group of placeList and boundaryCoordinates + */ + inner class ExplorePlacesInfo { + @JvmField + var explorePlaceList: List = emptyList() // List of nearby places + + @JvmField + var boundaryCoordinates: Array = emptyArray() // Corners of nearby area + + @JvmField + var currentLatLng: LatLng? = null // Current location when this places are populated + + @JvmField + var searchLatLng: LatLng? = null // Search location for finding this places + + @JvmField + var mediaList: List = emptyList() // Search location for finding this places + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt new file mode 100644 index 000000000..dbe722e91 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -0,0 +1,206 @@ +package fr.free.nrw.commons + +import android.os.Parcelable +import fr.free.nrw.commons.BuildConfig.COMMONS_URL +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.wikidata.model.WikiSite +import fr.free.nrw.commons.wikidata.model.page.PageTitle +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.util.Date +import java.util.Locale +import java.util.UUID + +@Parcelize +class Media constructor( + /** + * @return pageId for the current media object + * Wikibase Identifier associated with media files + */ + var pageId: String = UUID.randomUUID().toString(), + var thumbUrl: String? = null, + /** + * Gets image URL + * @return Image URL + */ + var imageUrl: String? = null, + /** + * Gets the name of the file. + * @return file name as a string + */ + var filename: String? = null, + /** + * The fallback description of the file, used if no other description is provided. + */ + var fallbackDescription: String? = null, + /** + * Gets the upload date of the file. + * Can be null. + * @return upload date as a Date + */ + var dateUploaded: Date? = null, + /** + * The license name of the file. + */ + var license: String? = null, + /** + * The URL corresponding to the license. + */ + var licenseUrl: String? = null, + /** + * The name of the creator of the file. + */ + var author: String? = null, + /** + * The username of the uploader. + */ + var user: String? = null, + /** + * The full name of the file's creator, if different from username. + */ + var creatorName: String? = null, + /** + * Gets the categories the file falls under. + * @return file categories as an ArrayList of Strings + */ + var categories: List? = null, + /** + * Gets the coordinates of where the file was created. + * @return file coordinates as a LatLng + */ + var coordinates: LatLng? = null, + var captions: Map = emptyMap(), + var descriptions: Map = emptyMap(), + var depictionIds: List = emptyList(), + var creatorIds: List = emptyList(), + /** + * This field was added to find non-hidden categories + * Stores the mapping of category title to hidden attribute + * Example: "Mountains" => false, "CC-BY-SA-2.0" => true + */ + var categoriesHiddenStatus: Map = emptyMap(), +) : Parcelable { + constructor( + captions: Map, + categories: List?, + filename: String?, + fallbackDescription: String?, + author: String?, + user: String?, + ) : this( + filename = filename, + fallbackDescription = fallbackDescription, + dateUploaded = Date(), + author = author, + user = user, + categories = categories, + captions = captions, + ) + + constructor( + captions: Map, + categories: List?, + filename: String?, + fallbackDescription: String?, + author: String?, + user: String?, + dateUploaded: Date? = Date(), + license: String? = null, + licenseUrl: String? = null, + imageUrl: String? = null, + thumbUrl: String? = null, + coordinates: LatLng? = null, + descriptions: Map = emptyMap(), + depictionIds: List = emptyList(), + categoriesHiddenStatus: Map = emptyMap() + ) : this( + pageId = UUID.randomUUID().toString(), + filename = filename, + fallbackDescription = fallbackDescription, + dateUploaded = dateUploaded, + author = author, + user = user, + categories = categories, + captions = captions, + license = license, + licenseUrl = licenseUrl, + imageUrl = imageUrl, + thumbUrl = thumbUrl, + coordinates = coordinates, + descriptions = descriptions, + depictionIds = depictionIds, + categoriesHiddenStatus = categoriesHiddenStatus + ) + + /** + * Returns Author if it's not null or empty, otherwise + * returns user + * @return Author or User + */ + @Deprecated("Use user for uploader username. Use attributedAuthor() for attribution. Note that the uploader may not be the creator/author.") + fun getAuthorOrUser(): String? { + return if (!author.isNullOrEmpty()) { + author + } else{ + user + } + } + + /** + * Returns author if it's not null or empty, otherwise + * returns creator name + * @return name of author or creator + */ + fun getAttributedAuthor(): String? { + return if (!author.isNullOrEmpty()) { + author + } else{ + creatorName + } + } + + /** + * Gets media display title + * @return Media title + */ + val displayTitle: String + get() = + if (filename != null) { + pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") + } else { + "" + } + + /** + * Gets file page title + * @return New media page title + */ + val pageTitle: PageTitle + get() = PageTitle(filename!!, WikiSite(COMMONS_URL)) + + /** + * Returns wikicode to use the media file on a MediaWiki site + * @return + */ + val wikiCode: String + get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) + + val mostRelevantCaption: String + get() = + captions[Locale.getDefault().language] + ?: captions.values.firstOrNull() + ?: displayTitle + + /** + * Gets the categories the file falls under. + * @return file categories as an ArrayList of Strings + */ + @IgnoredOnParcel + var addedCategories: List? = null + // TODO added categories should be removed. It is added for a short fix. On category update, + // categories should be re-fetched instead + get() = field // getter + set(value) { + field = value + } // setter +} diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt new file mode 100644 index 000000000..970413283 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt @@ -0,0 +1,72 @@ +package fr.free.nrw.commons + +import androidx.core.text.HtmlCompat +import fr.free.nrw.commons.media.IdAndLabels +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.media.PAGE_ID_PREFIX +import io.reactivex.Single +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Fetch additional media data from the network that we don't store locally. + * + * + * This includes things like category lists and multilingual descriptions, which are not intrinsic + * to the media and may change due to editing. + */ +@Singleton +class MediaDataExtractor + @Inject + constructor( + private val mediaClient: MediaClient, + ) { + fun fetchDepictionIdsAndLabels(media: Media) = + mediaClient + .getEntities(media.depictionIds) + .map { + it + .entities() + .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } + }.map { it.map { (key, value) -> IdAndLabels(key, value) } } + .onErrorReturn { emptyList() } + + fun fetchCreatorIdsAndLabels(media: Media) = + mediaClient + .getEntities(media.creatorIds) + .map { + it + .entities() + .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } + }.map { it.map { (key, value) -> IdAndLabels(key, value) } } + .onErrorReturn { emptyList() } + + fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) + + fun fetchDiscussion(media: Media) = + mediaClient + .getPageHtml(media.filename!!.replace("File", "File talk")) + .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } + .onErrorReturn { + Timber.d("Error occurred while fetching discussion") + "" + } + + fun refresh(media: Media): Single = + Single.ambArray( + mediaClient + .getMediaById(PAGE_ID_PREFIX + media.pageId) + .onErrorResumeNext { Single.never() }, + mediaClient + .getMediaSuppressingErrors(media.filename) + .onErrorResumeNext { Single.never() }, + ) + + fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title) + + /** + * Fetches wikitext from mediaClient + */ + fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title) + } diff --git a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt new file mode 100644 index 000000000..c54c3aefb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.kt @@ -0,0 +1,135 @@ +package fr.free.nrw.commons + +import androidx.annotation.VisibleForTesting +import fr.free.nrw.commons.wikidata.GsonUtil +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar +import fr.free.nrw.commons.wikidata.mwapi.MwErrorResponse +import fr.free.nrw.commons.wikidata.mwapi.MwIOException +import fr.free.nrw.commons.wikidata.mwapi.MwLegacyServiceError +import okhttp3.Cache +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +object OkHttpConnectionFactory { + private const val CACHE_DIR_NAME = "okhttp-cache" + private const val NET_CACHE_SIZE = (64 * 1024 * 1024).toLong() + + @VisibleForTesting + var CLIENT: OkHttpClient? = null + + fun getClient(cookieJar: CommonsCookieJar): OkHttpClient { + if (CLIENT == null) { + CLIENT = createClient(cookieJar) + } + return CLIENT!! + } + + private fun createClient(cookieJar: CommonsCookieJar): OkHttpClient { + return OkHttpClient.Builder() + .cookieJar(cookieJar) + .cache( + if (CommonsApplication.instance != null) Cache( + File(CommonsApplication.instance.cacheDir, CACHE_DIR_NAME), + NET_CACHE_SIZE + ) else null + ) + .connectTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BASIC) + redactHeader("Authorization") + redactHeader("Cookie") + }) + .addInterceptor(UnsuccessfulResponseInterceptor()) + .addInterceptor(CommonHeaderRequestInterceptor()) + .build() + } +} + +class CommonHeaderRequestInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("User-Agent", CommonsApplication.instance.userAgent) + .build() + return chain.proceed(request) + } +} + +private const val SUPPRESS_ERROR_LOG = "x-commons-suppress-error-log" +const val SUPPRESS_ERROR_LOG_HEADER: String = "$SUPPRESS_ERROR_LOG: true" + +private class UnsuccessfulResponseInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val rq = chain.request() + + // If the request contains our special "suppress errors" header, make note of it + // but don't pass that on to the server. + val suppressErrors = rq.headers.names().contains(SUPPRESS_ERROR_LOG) + val request = rq.newBuilder() + .removeHeader(SUPPRESS_ERROR_LOG) + .build() + + val rsp = chain.proceed(request) + + // Do not intercept certain requests and let the caller handle the errors + if (isExcludedUrl(chain.request())) { + return rsp + } + if (rsp.isSuccessful) { + try { + rsp.peekBody(ERRORS_PREFIX.length.toLong()).use { responseBody -> + if (ERRORS_PREFIX == responseBody.string()) { + rsp.body.use { body -> + val bodyString = body!!.string() + + throw MwIOException( + "MediaWiki API returned error: $bodyString", + GsonUtil.defaultGson.fromJson( + bodyString, + MwErrorResponse::class.java + ).error!!, + ) + } + } + } + } catch (e: MwIOException) { + // Log the error as debug (and therefore, "expected") or at error level + if (suppressErrors) { + Timber.d(e, "Suppressed (known / expected) error") + } else { + Timber.e(e) + throw e + } + } + return rsp + } + throw IOException("Unsuccessful response") + } + + private fun isExcludedUrl(request: Request): Boolean { + val requestUrl = request.url.toString() + for (url in DO_NOT_INTERCEPT) { + if (requestUrl.contains(url)) { + return true + } + } + return false + } + + companion object { + val DO_NOT_INTERCEPT = listOf( + "api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1" + ) + const val ERRORS_PREFIX = "{\"error" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/Urls.kt b/app/src/main/java/fr/free/nrw/commons/Urls.kt new file mode 100644 index 000000000..3eb7ee243 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/Urls.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons + +internal object Urls { + const val NEW_ISSUE_URL = "https://github.com/commons-app/apps-android-commons/issues" + const val GITHUB_REPO_URL = "https://github.com/commons-app/apps-android-commons" + const val GITHUB_PACKAGE_NAME = "com.github.android" + const val WEBSITE_URL = "https://commons-app.github.io" + const val CREDITS_URL = "https://github.com/commons-app/apps-android-commons/blob/master/CREDITS" + const val USER_GUIDE_URL = "https://commons-app.github.io/docs.html" + const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md" + const val PLAY_STORE_PREFIX = "market://details?id=" + const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id=" + const val TRANSLATE_WIKI_URL = + "https://translatewiki.net/w/i.php?title=Special:Translate" + + "&group=commons-android-strings&filter=%21translated&action=translate&language=" + const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985" + const val FACEBOOK_APP_URL = "fb://page/1921335171459985" + const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana" +} diff --git a/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt new file mode 100644 index 000000000..a8ce8c79a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.kt @@ -0,0 +1,44 @@ +package fr.free.nrw.commons + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import java.util.Locale + +/** + * This adapter will be used to display fragments in a ViewPager + */ +class ViewPagerAdapter : FragmentPagerAdapter { + private val context: Context + private var fragmentList: List = emptyList() + private var fragmentTitleList: List = emptyList() + + constructor(context: Context, manager: FragmentManager) : super(manager) { + this.context = context + } + + constructor(context: Context, manager: FragmentManager, behavior: Int) : super(manager, behavior) { + this.context = context + } + + override fun getItem(position: Int): Fragment = fragmentList[position] + + override fun getPageTitle(position: Int): CharSequence = fragmentTitleList[position] + + override fun getCount(): Int = fragmentList.size + + fun setTabs(vararg titlesToFragments: Pair) { + // Enforce that every title must come from strings.xml and all will consistently be uppercase + fragmentTitleList = titlesToFragments.map { + context.getString(it.first).uppercase(Locale.ROOT) + } + fragmentList = titlesToFragments.map { it.second } + } + + companion object { + // Convenience method for Java callers, can be removed when everything is migrated + @JvmStatic + fun pairOf(first: Int, second: Fragment) = first to second + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt new file mode 100644 index 000000000..0882ba117 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.kt @@ -0,0 +1,80 @@ +package fr.free.nrw.commons + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import fr.free.nrw.commons.databinding.ActivityWelcomeBinding +import fr.free.nrw.commons.databinding.PopupForCopyrightBinding +import fr.free.nrw.commons.quiz.QuizActivity +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour + +class WelcomeActivity : BaseActivity() { + private var binding: ActivityWelcomeBinding? = null + private var isQuiz = false + + /** + * Initialises exiting fields and dependencies + * + * @param savedInstanceState WelcomeActivity bundled data + */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityWelcomeBinding.inflate(layoutInflater) + applyEdgeToEdgeAllInsets(binding!!.welcomePager.rootView) + setContentView(binding!!.root) + + isQuiz = intent?.extras?.getBoolean("isQuiz", false) ?: false + + // Enable skip button if beta flavor + if (isBetaFlavour) { + binding!!.finishTutorialButton.visibility = View.VISIBLE + + val copyrightBinding = PopupForCopyrightBinding.inflate(layoutInflater) + + val dialog = AlertDialog.Builder(this) + .setView(copyrightBinding.root) + .setCancelable(false) + .create() + dialog.show() + + copyrightBinding.buttonOk.setOnClickListener { v: View? -> dialog.dismiss() } + } + + val adapter = WelcomePagerAdapter() + binding!!.welcomePager.adapter = adapter + binding!!.welcomePagerIndicator.setViewPager(binding!!.welcomePager) + binding!!.finishTutorialButton.setOnClickListener { v: View? -> finishTutorial() } + } + + public override fun onDestroy() { + if (isQuiz) { + startActivity(Intent(this, QuizActivity::class.java)) + } + super.onDestroy() + } + + override fun onBackPressed() { + if (binding!!.welcomePager.currentItem != 0) { + binding!!.welcomePager.setCurrentItem(binding!!.welcomePager.currentItem - 1, true) + } else { + if (defaultKvStore.getBoolean("firstrun", true)) { + finishAffinity() + } else { + super.onBackPressed() + } + } + } + + fun finishTutorial() { + defaultKvStore.putBoolean("firstrun", false) + finish() + } +} + +fun Context.startWelcome() { + startActivity(Intent(this, WelcomeActivity::class.java)) +} diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt new file mode 100644 index 000000000..0cb88c48b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/WelcomePagerAdapter.kt @@ -0,0 +1,70 @@ +package fr.free.nrw.commons + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.net.toUri +import androidx.viewpager.widget.PagerAdapter +import fr.free.nrw.commons.utils.UnderlineUtils.setUnderlinedText +import fr.free.nrw.commons.utils.handleWebUrl + +class WelcomePagerAdapter : PagerAdapter() { + /** + * Gets total number of layouts + * @return Number of layouts + */ + override fun getCount(): Int = PAGE_LAYOUTS.size + + /** + * Compares given view with provided object + * @param view Adapter view + * @param obj Adapter object + * @return Equality between view and object + */ + override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj) + + /** + * Provides a way to remove an item from container + * @param container Adapter view group container + * @param position Index of item + * @param obj Adapter object + */ + override fun destroyItem(container: ViewGroup, position: Int, obj: Any) = + container.removeView(obj as View) + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val inflater = LayoutInflater.from(container.context) + val layout = inflater.inflate(PAGE_LAYOUTS[position], container, false) as ViewGroup + + // If final page + if (position == PAGE_LAYOUTS.size - 1) { + // Add link to more information + val moreInfo = layout.findViewById(R.id.welcomeInfo) + setUnderlinedText(moreInfo, R.string.welcome_help_button_text) + moreInfo.setOnClickListener { + handleWebUrl( + container.context, + "https://commons.wikimedia.org/wiki/Help:Contents".toUri() + ) + } + + // Handle click of finishTutorialButton ("YES!" button) inside layout + layout.findViewById(R.id.finishTutorialButton) + .setOnClickListener { view: View? -> (container.context as WelcomeActivity).finishTutorial() } + } + + container.addView(layout) + return layout + } + + companion object { + private val PAGE_LAYOUTS = intArrayOf( + R.layout.welcome_wikipedia, + R.layout.welcome_do_upload, + R.layout.welcome_dont_upload, + R.layout.welcome_image_example, + R.layout.welcome_final + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt new file mode 100644 index 000000000..f49dd7705 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/actions/MwThankPostResponse.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.actions + +import fr.free.nrw.commons.wikidata.mwapi.MwResponse + +/** + * Response of the Thanks API. + * Context: + * The Commons Android app lets you thank other contributors who have uploaded a great picture. + * See https://www.mediawiki.org/wiki/Extension:Thanks + */ +class MwThankPostResponse : MwResponse() { + var result: Result? = null + + inner class Result { + var success: Int? = null + var recipient: String? = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt new file mode 100644 index 000000000..a3d6de257 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt @@ -0,0 +1,201 @@ +package fr.free.nrw.commons.actions + +import fr.free.nrw.commons.auth.csrf.CsrfTokenClient +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import io.reactivex.Observable +import io.reactivex.Single + +/** + * This class acts as a Client to facilitate wiki page editing + * services to various dependency providing modules such as the Network module, the Review Controller, etc. + * + * The methods provided by this class will post to the Media wiki api + * documented at: https://commons.wikimedia.org/w/api.php?action=help&modules=edit + */ +class PageEditClient( + private val csrfTokenClient: CsrfTokenClient, + private val pageEditInterface: PageEditInterface, +) { + /** + * Replace the content of a wiki page + * @param pageTitle Title of the page to edit + * @param text Holds the page content + * @param summary Edit summary + * @return whether the edit was successful + */ + fun edit( + pageTitle: String, + text: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) + .map { editResponse -> + editResponse.edit()!!.editSucceeded() + } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Creates a new page with the given title, text, and summary. + * + * @param pageTitle The title of the page to be created. + * @param text The content of the page in wikitext format. + * @param summary The edit summary for the page creation. + * @return An observable that emits true if the page creation succeeded, false otherwise. + * @throws InvalidLoginTokenException If an invalid login token is encountered during the process. + */ + fun postCreate( + pageTitle: String, + text: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postCreate( + pageTitle, + summary, + text, + "text/x-wiki", + "wikitext", + true, + true, + csrfTokenClient.getTokenBlocking(), + ).map { editResponse -> + editResponse.edit()!!.editSucceeded() + } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Append text to the end of a wiki page + * @param pageTitle Title of the page to edit + * @param appendText The received page content is added to the end of the page + * @param summary Edit summary + * @return whether the edit was successful + */ + fun appendEdit( + pageTitle: String, + appendText: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) + .map { editResponse -> editResponse.edit()!!.editSucceeded() } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Prepend text to the beginning of a wiki page + * @param pageTitle Title of the page to edit + * @param prependText The received page content is added to the beginning of the page + * @param summary Edit summary + * @return whether the edit was successful + */ + fun prependEdit( + pageTitle: String, + prependText: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) + .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Appends a new section to the wiki page + * @param pageTitle Title of the page to edit + * @param sectionTitle Title of the new section that needs to be created + * @param sectionText The page content that is to be added to the section + * @param summary Edit summary + * @return whether the edit was successful + */ + fun createNewSection( + pageTitle: String, + sectionTitle: String, + sectionText: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking()) + .map { editResponse -> editResponse.edit()!!.editSucceeded() } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Set new labels to Wikibase server of commons + * @param summary Edit summary + * @param title Title of the page to edit + * @param language Corresponding language of label + * @param value label + * @return 1 when the edit was successful + */ + fun setCaptions( + summary: String, + title: String, + language: String, + value: String, + ): Observable = + try { + pageEditInterface + .postCaptions( + summary, + title, + language, + value, + csrfTokenClient.getTokenBlocking(), + ).map { it.success } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(0) + } + } + + /** + * Get whole WikiText of required file + * @param title : Name of the file + * @return Observable + */ + fun getCurrentWikiText(title: String): Single = + pageEditInterface.getWikiText(title).map { + it + .query() + ?.pages() + ?.get(0) + ?.revisions() + ?.get(0) + ?.content() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt new file mode 100644 index 000000000..5e2651039 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt @@ -0,0 +1,141 @@ +package fr.free.nrw.commons.actions + +import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX +import fr.free.nrw.commons.wikidata.model.Entities +import fr.free.nrw.commons.wikidata.model.edit.Edit +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Observable +import io.reactivex.Single +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * This interface facilitates wiki commons page editing services to the Networking module + * which provides all network related services used by the app. + * + * This interface posts a form encoded request to the wikimedia API + * with editing action as argument to edit a particular page + */ +interface PageEditInterface { + /** + * This method posts such that the Content which the page + * has will be completely replaced by the value being passed to the + * "text" field of the encoded form data + * @param title Title of the page to edit. Cannot be used together with pageid. + * @param summary Edit summary. Also section title when section=new and sectiontitle is not set + * @param text Holds the page content + * @param token A "csrf" token + */ + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=edit") + fun postEdit( + @Field("title") title: String, + @Field("summary") summary: String, + @Field("text") text: String, + // NOTE: This csrf shold always be sent as the last field of form data + @Field("token") token: String, + ): Observable + + /** + * This method creates or edits a page for nearby items. + * + * @param title Title of the page to edit. Cannot be used together with pageid. + * @param summary Edit summary. Also used as the section title when section=new and sectiontitle is not set. + * @param text Text of the page. + * @param contentformat Format of the content (e.g., "text/x-wiki"). + * @param contentmodel Model of the content (e.g., "wikitext"). + * @param minor Whether the edit is a minor edit. + * @param recreate Whether to recreate the page if it does not exist. + * @param token A "csrf" token. This should always be sent as the last field of form data. + */ + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=edit") + fun postCreate( + @Field("title") title: String, + @Field("summary") summary: String, + @Field("text") text: String, + @Field("contentformat") contentformat: String, + @Field("contentmodel") contentmodel: String, + @Field("minor") minor: Boolean, + @Field("recreate") recreate: Boolean, + // NOTE: This csrf shold always be sent as the last field of form data + @Field("token") token: String, + ): Observable + + /** + * This method posts such that the Content which the page + * has will be appended with the value being passed to the + * "appendText" field of the encoded form data + * @param title Title of the page to edit. Cannot be used together with pageid. + * @param summary Edit summary. Also section title when section=new and sectiontitle is not set + * @param appendText Text to add to the end of the page + * @param token A "csrf" token + */ + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=edit") + fun postAppendEdit( + @Field("title") title: String, + @Field("summary") summary: String, + @Field("appendtext") appendText: String, + @Field("token") token: String, + ): Observable + + /** + * This method posts such that the Content which the page + * has will be prepended with the value being passed to the + * "prependText" field of the encoded form data + * @param title Title of the page to edit. Cannot be used together with pageid. + * @param summary Edit summary. Also section title when section=new and sectiontitle is not set + * @param prependText Text to add to the beginning of the page + * @param token A "csrf" token + */ + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=edit") + fun postPrependEdit( + @Field("title") title: String, + @Field("summary") summary: String, + @Field("prependtext") prependText: String, + @Field("token") token: String, + ): Observable + + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=edit§ion=new") + fun postNewSection( + @Field("title") title: String, + @Field("summary") summary: String, + @Field("sectiontitle") sectionTitle: String, + @Field("text") sectionText: String, + @Field("token") token: String, + ): Observable + + @FormUrlEncoded + @Headers("Cache-Control: no-cache") + @POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2") + fun postCaptions( + @Field("summary") summary: String, + @Field("title") title: String, + @Field("language") language: String, + @Field("value") value: String, + @Field("token") token: String, + ): Observable + + /** + * Gets the wiki text for the provided file name. + * + * @param title The title (name) of the file to fetch wiki text for. + * @return A Single emitting the wiki query response. + */ + @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") + fun getWikiText( + @Query("titles") title: String, + ): Single +} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt new file mode 100644 index 000000000..1dcf93edf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.actions + +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.auth.csrf.CsrfTokenClient +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF +import io.reactivex.Observable +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Client for the Wkikimedia Thanks API extension + * Thanks are used by a user to show gratitude to another user for their contributions + */ +@Singleton +class ThanksClient + @Inject + constructor( + @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, + private val service: ThanksInterface, + ) { + /** + * Thanks a user for a particular revision + * @param revisionId The revision ID the user would like to thank someone for + * @return if thanks was successfully sent to intended recipient + */ + fun thank(revisionId: Long): Observable = + try { + service + .thank( + revisionId.toString(), // Rev + null, // Log + csrfTokenClient.getTokenBlocking(), // Token + CommonsApplication.instance.userAgent, // Source + ).map { mwThankPostResponse -> + mwThankPostResponse.result?.success == 1 + } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + Observable.error(throwable) + } else { + Observable.just(false) + } + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt new file mode 100644 index 000000000..62934d0f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.actions + +import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX +import io.reactivex.Observable +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +/** + * Thanks API. + * Context: + * The Commons Android app lets you thank another contributor who has uploaded a great picture. + * See https://www.mediawiki.org/wiki/Extension:Thanks + */ +interface ThanksInterface { + @FormUrlEncoded + @POST(MW_API_PREFIX + "action=thank") + fun thank( + @Field("rev") rev: String?, + @Field("log") log: String?, + @Field("token") token: String, + @Field("source") source: String?, + ): Observable +} diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt new file mode 100644 index 000000000..0710e2551 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt @@ -0,0 +1,218 @@ +package fr.free.nrw.commons.activity + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.webkit.ConsoleMessage +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener +import fr.free.nrw.commons.R +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import timber.log.Timber +import javax.inject.Inject + +/** + * SingleWebViewActivity is a reusable activity webView based on a given url(initial url) and + * closes itself when a specified success URL is reached to success url. + */ +class SingleWebViewActivity : ComponentActivity() { + @Inject + lateinit var cookieJar: CommonsCookieJar + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(VANISH_ACCOUNT_URL) + val successUrl = intent.getStringExtra(VANISH_ACCOUNT_SUCCESS_URL) + if (url == null || successUrl == null) { + finish() + return + } + ApplicationlessInjection + .getInstance(applicationContext) + .commonsApplicationComponent + .inject(this) + setCookies(url) + enableEdgeToEdge() + setContent { + Scaffold( + topBar = { + TopAppBar( + modifier = Modifier, + title = { Text(getString(R.string.vanish_account)) }, + navigationIcon = { + IconButton( + onClick = { + // Close the WebView Activity if the user taps the back button + finish() + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + // TODO("Add contentDescription) + contentDescription = "" + ) + } + } + ) + }, + content = { + WebViewComponent( + url = url, + successUrl = successUrl, + onSuccess = { + //Redirect the user to login screen like we do when the user logout's + val app = applicationContext as CommonsApplication + app.clearApplicationData( + applicationContext, + ActivityLogoutListener(activity = this, ctx = applicationContext) + ) + finish() + }, + modifier = Modifier + .fillMaxSize() + .padding(it) + ) + } + ) + } + } + + + /** + * @param url The initial URL which we are loading in the WebView. + * @param successUrl The URL that, when reached, triggers the `onSuccess` callback. + * @param onSuccess A callback that is invoked when the current url of webView is successUrl. + * This is used when we want to close when the webView once a success url is hit. + * @param modifier An optional [Modifier] to customize the layout or appearance of the WebView. + */ + @SuppressLint("SetJavaScriptEnabled") + @Composable + private fun WebViewComponent( + url: String, + successUrl: String, + onSuccess: () -> Unit, + modifier: Modifier = Modifier + ) { + val webView = remember { mutableStateOf(null) } + AndroidView( + modifier = modifier, + factory = { + WebView(it).apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + javaScriptCanOpenWindowsAutomatically = true + + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + + request?.url?.let { url -> + Timber.d("URL Loading: $url") + if (url.toString() == successUrl) { + Timber.d("Success URL detected. Closing WebView.") + onSuccess() // Close the activity + return true + } + return false + } + return false + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + setCookies(url.orEmpty()) + } + + } + + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(message: ConsoleMessage): Boolean { + Timber.d("%s%s", + "Console: ${message.message()} -- From line ", + "${message.lineNumber()} of ${message.sourceId()}") + return true + } + } + + loadUrl(url) + } + }, + update = { + webView.value = it + } + ) + + } + + /** + * Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`. + * + * @param url The URL for which cookies need to be set. + */ + private fun setCookies(url: String) { + CookieManager.getInstance().let { + val cookies = cookieJar.loadForRequest(url.toHttpUrl()) + for (cookie in cookies) { + it.setCookie(url, cookie.toString()) + } + } + } + + companion object { + private const val VANISH_ACCOUNT_URL = "VanishAccountUrl" + private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl" + + /** + * Launch the WebViewActivity with the specified URL and success URL. + * @param context The context from which the activity is launched. + * @param url The initial URL to load in the WebView. + * @param successUrl The URL that triggers the WebView to close when matched. + */ + fun showWebView( + context: Context, + url: String, + successUrl: String + ) { + val intent = Intent( + context, + SingleWebViewActivity::class.java + ).apply { + putExtra(VANISH_ACCOUNT_URL, url) + putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl) + } + context.startActivity(intent) + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt new file mode 100644 index 000000000..aa86cd0d8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import androidx.annotation.VisibleForTesting +import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE +import timber.log.Timber + +const val AUTH_TOKEN_TYPE: String = "CommonsAndroid" + +fun getUserName(context: Context): String? { + return account(context)?.name +} + +@VisibleForTesting +fun account(context: Context): Account? = try { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE) + if (accounts.isNotEmpty()) accounts[0] else null +} catch (e: SecurityException) { + Timber.e(e) + null +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt new file mode 100644 index 000000000..0c9901b56 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt @@ -0,0 +1,489 @@ +package fr.free.nrw.commons.auth + +import android.accounts.AccountAuthenticatorActivity +import android.app.ProgressDialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.KeyEvent +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NavUtils +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.login.LoginCallback +import fr.free.nrw.commons.auth.login.LoginClient +import fr.free.nrw.commons.auth.login.LoginResult +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.ActivityLoginBinding +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets +import fr.free.nrw.commons.utils.AbstractTextWatcher +import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.SystemThemeUtils +import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard +import fr.free.nrw.commons.utils.handleKeyboardInsets +import fr.free.nrw.commons.utils.handleWebUrl +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + +class LoginActivity : AccountAuthenticatorActivity() { + @Inject + lateinit var sessionManager: SessionManager + + @Inject + @field:Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + + @Inject + lateinit var loginClient: LoginClient + + @Inject + lateinit var systemThemeUtils: SystemThemeUtils + + private var binding: ActivityLoginBinding? = null + private var progressDialog: ProgressDialog? = null + private val textWatcher = AbstractTextWatcher(::onTextChanged) + private val compositeDisposable = CompositeDisposable() + private val delegate: AppCompatDelegate by lazy { + AppCompatDelegate.create(this, null) + } + private var lastLoginResult: LoginResult? = null + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ApplicationlessInjection + .getInstance(this.applicationContext) + .commonsApplicationComponent + .inject(this) + + val isDarkTheme = systemThemeUtils.isDeviceInNightMode() + setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme) + delegate.installViewFactory() + delegate.onCreate(savedInstanceState) + + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = !isDarkTheme + + WindowCompat.setDecorFitsSystemWindows(window, false) + + binding = ActivityLoginBinding.inflate(layoutInflater) + applyEdgeToEdgeAllInsets(binding!!.root) + binding!!.root.handleKeyboardInsets() + with(binding!!) { + setContentView(root) + + loginUsername.addTextChangedListener(textWatcher) + loginPassword.addTextChangedListener(textWatcher) + loginTwoFactor.addTextChangedListener(textWatcher) + + skipLogin.setOnClickListener { skipLogin() } + forgotPassword.setOnClickListener { forgotPassword() } + aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() } + signUpButton.setOnClickListener { signUp() } + loginButton.setOnClickListener { performLogin() } + loginPassword.setOnEditorActionListener { textView, actionId, keyEvent -> + if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) { + if (actionId == EditorInfo.IME_ACTION_NEXT && lastLoginResult != null) { + askUserForTwoFactorAuthWithKeyboard() + true + } else { + performLogin() + true + } + } else { + false + } + } + + loginPassword.onFocusChangeListener = + View.OnFocusChangeListener(::onPasswordFocusChanged) + + if (isBetaFlavour) { + loginCredentials.text = getString(R.string.login_credential) + } else { + loginCredentials.visibility = View.GONE + } + + intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let { + showMessage(it, R.color.secondaryDarkColor) + } + + intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let { + loginUsername.setText(it) + } + } + } + + @VisibleForTesting + fun askUserForTwoFactorAuthWithKeyboard() { + if (binding == null) { + Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuthWithKeyboard") + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding!!.root) + } + progressDialog!!.dismiss() + if (binding != null) { + with(binding!!) { + twoFactorContainer.visibility = View.VISIBLE + twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code) + loginTwoFactor.visibility = View.VISIBLE + loginTwoFactor.requestFocus() + + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(loginTwoFactor, InputMethodManager.SHOW_IMPLICIT) + + loginTwoFactor.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE || + (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) { + performLogin() + true + } else { + false + } + } + } + } else { + Timber.e("Binding is null in askUserForTwoFactorAuthWithKeyboard after reinitialization attempt") + } + showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed)) + } + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + delegate.onPostCreate(savedInstanceState) + } + + override fun onResume() { + super.onResume() + + if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) { + applicationKvStore.putBoolean("login_skipped", false) + startMainActivity() + } + + if (applicationKvStore.getBoolean("login_skipped", false)) { + performSkipLogin() + } + } + + override fun onDestroy() { + compositeDisposable.clear() + try { + // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method + if (progressDialog?.isShowing == true) { + progressDialog!!.dismiss() + } + } catch (e: Exception) { + e.printStackTrace() + } + with(binding!!) { + loginUsername.removeTextChangedListener(textWatcher) + loginPassword.removeTextChangedListener(textWatcher) + loginTwoFactor.removeTextChangedListener(textWatcher) + } + delegate.onDestroy() + loginClient.cancel() + binding = null + super.onDestroy() + } + + override fun onStart() { + super.onStart() + delegate.onStart() + } + + override fun onStop() { + super.onStop() + delegate.onStop() + } + + override fun onPostResume() { + super.onPostResume() + delegate.onPostResume() + } + + override fun setContentView(view: View, params: ViewGroup.LayoutParams) { + delegate.setContentView(view, params) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + NavUtils.navigateUpFromSameTask(this) + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onSaveInstanceState(outState: Bundle) { + // if progressDialog is visible during the configuration change then store state as true else false so that + // we maintain visibility of progressDialog after configuration change + if (progressDialog != null && progressDialog!!.isShowing) { + outState.putBoolean(SAVE_PROGRESS_DIALOG, true) + } else { + outState.putBoolean(SAVE_PROGRESS_DIALOG, false) + } + outState.putString( + SAVE_ERROR_MESSAGE, + binding!!.errorMessage.text.toString() + ) //Save the errorMessage + outState.putString( + SAVE_USERNAME, + binding!!.loginUsername.text.toString() + ) // Save the username + outState.putString( + SAVE_PASSWORD, + binding!!.loginPassword.text.toString() + ) // Save the password + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME)) + binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD)) + if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) { + performLogin() + } + val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE) + if (sessionManager.isUserLoggedIn) { + showMessage(R.string.login_success, R.color.primaryDarkColor) + } else { + showMessage(errorMessage, R.color.secondaryDarkColor) + } + } + + /** + * Hides the keyboard if the user's focus is not on the password (hasFocus is false). + * @param view The keyboard + * @param hasFocus Set to true if the keyboard has focus + */ + private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) { + if (!hasFocus) { + hideKeyboard(view) + } + } + + private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) = + if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) { + performLogin() + true + } else false + + private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) = + actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER + + private fun skipLogin() { + AlertDialog.Builder(this) + .setTitle(R.string.skip_login_title) + .setMessage(R.string.skip_login_message) + .setCancelable(false) + .setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int -> + dialog.cancel() + performSkipLogin() + } + .setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int -> + dialog.cancel() + } + .show() + } + + private fun forgotPassword() = + handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL)) + + private fun onPrivacyPolicyClicked() = + handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)) + + private fun signUp() = + startActivity(Intent(this, SignupActivity::class.java)) + + @VisibleForTesting + fun performLogin() { + Timber.d("Login to start!") + val username = binding!!.loginUsername.text.toString() + val password = binding!!.loginPassword.text.toString() + val twoFactorCode = binding!!.loginTwoFactor.text.toString() + + showLoggingProgressBar() + loginClient.doLogin(username, + password, + lastLoginResult, + twoFactorCode, + Locale.getDefault().language, + object : LoginCallback { + override fun success(loginResult: LoginResult) = runOnUiThread { + Timber.d("Login Success") + progressDialog!!.dismiss() + onLoginSuccess(loginResult) + } + + override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread { + Timber.d("Requesting 2FA prompt") + progressDialog!!.dismiss() + lastLoginResult = loginResult + askUserForTwoFactorAuthWithKeyboard() + } + + override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread { + Timber.d("Requesting email auth prompt") + progressDialog!!.dismiss() + lastLoginResult = loginResult + askUserForTwoFactorAuthWithKeyboard() + } + + override fun passwordResetPrompt(token: String?) = runOnUiThread { + Timber.d("Showing password reset prompt") + progressDialog!!.dismiss() + showPasswordResetPrompt() + } + + override fun error(caught: Throwable) = runOnUiThread { + Timber.e(caught) + progressDialog!!.dismiss() + showMessageAndCancelDialog(caught.localizedMessage ?: "") + } + } + ) + } + + private fun showPasswordResetPrompt() = + showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword)) + + /** + * This function is called when user skips the login. + * It redirects the user to Explore Activity. + */ + private fun performSkipLogin() { + applicationKvStore.putBoolean("login_skipped", true) + MainActivity.startYourself(this) + finish() + } + + private fun showLoggingProgressBar() { + progressDialog = ProgressDialog(this).apply { + isIndeterminate = true + setTitle(getString(R.string.logging_in_title)) + setMessage(getString(R.string.logging_in_message)) + setCancelable(false) + } + progressDialog!!.show() + } + + private fun onLoginSuccess(loginResult: LoginResult) { + compositeDisposable.clear() + sessionManager.setUserLoggedIn(true) + sessionManager.updateAccount(loginResult) + progressDialog!!.dismiss() + showSuccessAndDismissDialog() + startMainActivity() + } + + override fun getMenuInflater(): MenuInflater = + delegate.menuInflater + + @VisibleForTesting + fun askUserForTwoFactorAuth() { + if (binding == null) { + Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuth") + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding!!.root) + } + progressDialog!!.dismiss() + if (binding != null) { + with(binding!!) { + twoFactorContainer.visibility = View.VISIBLE + twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code) + loginTwoFactor.visibility = View.VISIBLE + loginTwoFactor.requestFocus() + + loginTwoFactor.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE || + (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) { + performLogin() + true + } else { + false + } + } + } + } else { + Timber.e("Binding is null in askUserForTwoFactorAuth after reinitialization attempt") + } + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY) + showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed)) + } + + @VisibleForTesting + fun showMessageAndCancelDialog(@StringRes resId: Int) { + showMessage(resId, R.color.secondaryDarkColor) + progressDialog?.cancel() + } + + @VisibleForTesting + fun showMessageAndCancelDialog(error: String) { + showMessage(error, R.color.secondaryDarkColor) + progressDialog?.cancel() + } + + @VisibleForTesting + fun showSuccessAndDismissDialog() { + showMessage(R.string.login_success, R.color.primaryDarkColor) + progressDialog!!.dismiss() + } + + @VisibleForTesting + fun startMainActivity() { + startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP) + finish() + } + + private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) { + errorMessage.text = getString(resId) + errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId)) + errorMessageContainer.visibility = View.VISIBLE + } + + private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) { + errorMessage.text = message + errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId)) + errorMessageContainer.visibility = View.VISIBLE + } + + private fun onTextChanged(text: String) { + val enabled = + binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 && + (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE) + binding!!.loginButton.isEnabled = enabled + } + + companion object { + fun startYourself(context: Context) = + context.startActivity(Intent(context, LoginActivity::class.java)) + + const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state" + const val SAVE_ERROR_MESSAGE: String = "errorMessage" + const val SAVE_USERNAME: String = "username" + const val SAVE_PASSWORD: String = "password" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt new file mode 100644 index 000000000..c9eb7d2f1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt @@ -0,0 +1,95 @@ +package fr.free.nrw.commons.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Build +import android.text.TextUtils +import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE +import fr.free.nrw.commons.auth.login.LoginResult +import fr.free.nrw.commons.kvstore.JsonKvStore +import io.reactivex.Completable +import io.reactivex.Observable +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Manage the current logged in user session. + */ +@Singleton +class SessionManager @Inject constructor( + private val context: Context, + @param:Named("default_preferences") private val defaultKvStore: JsonKvStore +) { + private val accountManager: AccountManager get() = AccountManager.get(context) + + private var _currentAccount: Account? = null // Unlike a savings account... ;-) + val currentAccount: Account? get() { + if (_currentAccount == null) { + val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE) + if (allAccounts.isNotEmpty()) { + _currentAccount = allAccounts[0] + } + } + return _currentAccount + } + + val userName: String? + get() = currentAccount?.name + + var password: String? + get() = currentAccount?.let { accountManager.getPassword(it) } + private set(value) { + currentAccount?.let { accountManager.setPassword(it, value) } + } + + val isUserLoggedIn: Boolean + get() = defaultKvStore.getBoolean("isUserLoggedIn", false) + + fun updateAccount(result: LoginResult) { + if (createAccount(result.userName!!, result.password!!)) { + password = result.password + } + } + + fun doesAccountExist(): Boolean = + currentAccount != null + + fun setUserLoggedIn(isLoggedIn: Boolean) = + defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn) + + fun forceLogin(context: Context?) = + context?.let { LoginActivity.startYourself(it) } + + fun getPreference(key: String): Boolean = + defaultKvStore.getBoolean(key) + + fun logout(): Completable = Completable.fromObservable( + Observable.empty() + .doOnComplete { + removeAccount() + _currentAccount = null + } + ) + + private fun createAccount(userName: String, password: String): Boolean { + var account = currentAccount + if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) { + removeAccount() + account = Account(userName, ACCOUNT_TYPE) + return accountManager.addAccountExplicitly(account, password, null) + } + return true + } + + private fun removeAccount() { + currentAccount?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + accountManager.removeAccountExplicitly(it) + } else { + accountManager.removeAccount(it, null, null) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt new file mode 100644 index 000000000..22f557bcd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt @@ -0,0 +1,77 @@ +package fr.free.nrw.commons.auth + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.R +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets +import timber.log.Timber + +class SignupActivity : BaseActivity() { + private var webView: WebView? = null + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Timber.d("Signup Activity started") + + webView = WebView(this) + applyEdgeToEdgeAllInsets(webView!!) + with(webView!!) { + setContentView(this) + webViewClient = MyWebViewClient() + // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can + // trust Wikimedia's site... right? + settings.javaScriptEnabled = true + loadUrl(BuildConfig.SIGNUP_LANDING_URL) + } + } + + override fun onBackPressed() { + if (webView!!.canGoBack()) { + webView!!.goBack() + } else { + super.onBackPressed() + } + } + + /** + * Known bug in androidx.appcompat library version 1.1.0 being tracked here + * https://issuetracker.google.com/issues/141132133 + * App tries to put light/dark theme to webview and crashes in the process + * This code tries to prevent applying the theme when sdk is between api 21 to 25 + */ + override fun applyOverrideConfiguration(overrideConfiguration: Configuration) { + if (Build.VERSION.SDK_INT <= 25 && + (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode) + ) return + super.applyOverrideConfiguration(overrideConfiguration) + } + + private inner class MyWebViewClient : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = + if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) { + //Signup success, so clear cookies, notify user, and load LoginActivity again + Timber.d("Overriding URL %s", url) + + Toast.makeText( + this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG + ).show() + + // terminate on task completion. + finish() + true + } else { + //If user clicks any other links in the webview + Timber.d("Not overriding URL, URL is: %s", url) + false + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt new file mode 100644 index 000000000..367989f14 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt @@ -0,0 +1,108 @@ +package fr.free.nrw.commons.auth + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.accounts.NetworkErrorException +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.os.bundleOf +import fr.free.nrw.commons.BuildConfig + +private val SYNC_AUTHORITIES = arrayOf( + BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY +) + +/** + * Handles WikiMedia commons account Authentication + */ +class WikiAccountAuthenticator( + private val context: Context +) : AbstractAccountAuthenticator(context) { + /** + * Provides Bundle with edited Account Properties + */ + override fun editProperties( + response: AccountAuthenticatorResponse, + accountType: String + ) = bundleOf("test" to "editProperties") + + // account type not supported returns bundle without loginActivity Intent, it just contains "test" key + @Throws(NetworkErrorException::class) + override fun addAccount( + response: AccountAuthenticatorResponse, + accountType: String, + authTokenType: String?, + requiredFeatures: Array?, + options: Bundle? + ) = if (BuildConfig.ACCOUNT_TYPE == accountType) { + addAccount(response) + } else { + bundleOf("test" to "addAccount") + } + + @Throws(NetworkErrorException::class) + override fun confirmCredentials( + response: AccountAuthenticatorResponse, account: Account, options: Bundle? + ) = bundleOf("test" to "confirmCredentials") + + @Throws(NetworkErrorException::class) + override fun getAuthToken( + response: AccountAuthenticatorResponse, + account: Account, + authTokenType: String, + options: Bundle? + ) = bundleOf("test" to "getAuthToken") + + override fun getAuthTokenLabel(authTokenType: String) = + if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null + + @Throws(NetworkErrorException::class) + override fun updateCredentials( + response: AccountAuthenticatorResponse, + account: Account, + authTokenType: String?, + options: Bundle? + ) = bundleOf("test" to "updateCredentials") + + @Throws(NetworkErrorException::class) + override fun hasFeatures( + response: AccountAuthenticatorResponse, + account: Account, features: Array + ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false) + + /** + * Provides a bundle containing a Parcel + * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type) + */ + private fun addAccount(response: AccountAuthenticatorResponse): Bundle { + val intent = Intent(context, LoginActivity::class.java) + .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + return bundleOf(AccountManager.KEY_INTENT to intent) + } + + @Throws(NetworkErrorException::class) + override fun getAccountRemovalAllowed( + response: AccountAuthenticatorResponse?, + account: Account? + ): Bundle { + val result = super.getAccountRemovalAllowed(response, account) + + if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) + && !result.containsKey(AccountManager.KEY_INTENT) + ) { + val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT) + + if (allowed) { + for (auth in SYNC_AUTHORITIES) { + ContentResolver.cancelSync(account, auth) + } + } + } + + return result + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt new file mode 100644 index 000000000..852536a48 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.auth + +import android.accounts.AbstractAccountAuthenticator +import android.content.Intent +import android.os.IBinder +import fr.free.nrw.commons.di.CommonsDaggerService + +/** + * Handles the Auth service of the App, see AndroidManifests for details + * (Uses Dagger 2 as injector) + */ +class WikiAccountAuthenticatorService : CommonsDaggerService() { + private var authenticator: AbstractAccountAuthenticator? = null + + override fun onCreate() { + super.onCreate() + authenticator = WikiAccountAuthenticator(this) + } + + override fun onBind(intent: Intent): IBinder? = + authenticator?.iBinder +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt new file mode 100644 index 000000000..6353e54ac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt @@ -0,0 +1,217 @@ +package fr.free.nrw.commons.auth.csrf + +import androidx.annotation.VisibleForTesting +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.login.LoginCallback +import fr.free.nrw.commons.auth.login.LoginClient +import fr.free.nrw.commons.auth.login.LoginFailedException +import fr.free.nrw.commons.auth.login.LoginResult +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import retrofit2.Call +import retrofit2.Response +import timber.log.Timber +import java.util.concurrent.Callable +import java.util.concurrent.Executors.newSingleThreadExecutor + +class CsrfTokenClient( + private val sessionManager: SessionManager, + private val csrfTokenInterface: CsrfTokenInterface, + private val loginClient: LoginClient, + private val logoutClient: LogoutClient, +) { + private var retries = 0 + private var csrfTokenCall: Call? = null + + @Throws(Throwable::class) + fun getTokenBlocking(): String { + var token = "" + val userName = sessionManager.userName ?: "" + val password = sessionManager.password ?: "" + + for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) { + try { + if (retry > 0) { + // Log in explicitly + loginClient.loginBlocking(userName, password) + } + + // Get CSRFToken response off the main thread. + val response = + newSingleThreadExecutor() + .submit( + Callable { + csrfTokenInterface.getCsrfTokenCall().execute() + }, + ).get() + + if (response + .body() + ?.query() + ?.csrfToken() + .isNullOrEmpty() + ) { + continue + } + + token = response.body()!!.query()!!.csrfToken()!! + if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { + throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } + break + } catch (e: LoginFailedException) { + throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } catch (t: Throwable) { + Timber.w(t) + } + } + + if (token.isEmpty() || token == ANON_TOKEN) { + throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } + return token + } + + @VisibleForTesting + fun request( + service: CsrfTokenInterface, + cb: Callback, + ): Call = + requestToken( + service, + object : Callback { + override fun success(token: String?) { + if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { + retryWithLogin(cb) { + InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } + } else { + cb.success(token) + } + } + + override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } + + override fun twoFactorPrompt() = cb.twoFactorPrompt() + + override fun emailAuthPrompt() = cb.emailAuthPrompt() + }, + ) + + @VisibleForTesting + fun requestToken( + service: CsrfTokenInterface, + cb: Callback, + ): Call { + val call = service.getCsrfTokenCall() + call.enqueue( + object : retrofit2.Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (call.isCanceled) { + return + } + cb.success(response.body()!!.query()!!.csrfToken()) + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.failure(t) + } + }, + ) + return call + } + + private fun retryWithLogin( + callback: Callback, + caught: () -> Throwable?, + ) { + val userName = sessionManager.userName + val password = sessionManager.password + if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { + retries++ + logoutClient.logout() + login(userName, password, callback) { + Timber.i("retrying...") + cancel() + csrfTokenCall = request(csrfTokenInterface, callback) + } + } else { + callback.failure(caught()) + } + } + + private fun login( + username: String, + password: String, + callback: Callback, + retryCallback: () -> Unit, + ) = loginClient.request( + username, + password, + object : LoginCallback { + override fun success(loginResult: LoginResult) { + if (loginResult.pass) { + sessionManager.updateAccount(loginResult) + retryCallback() + } else { + callback.failure(LoginFailedException(loginResult.message)) + } + } + + override fun twoFactorPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) = callback.twoFactorPrompt() + + override fun emailAuthPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) = callback.emailAuthPrompt() + + // Should not happen here, but call the callback just in case. + override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) + + override fun error(caught: Throwable) = callback.failure(caught) + }, + ) + + private fun cancel() { + loginClient.cancel() + if (csrfTokenCall != null) { + csrfTokenCall!!.cancel() + csrfTokenCall = null + } + } + + interface Callback { + fun success(token: String?) + + fun failure(caught: Throwable?) + + fun twoFactorPrompt() + + fun emailAuthPrompt() + } + + companion object { + private const val ANON_TOKEN = "+\\" + private const val MAX_RETRIES = 1 + private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2 + const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure." + const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." + } +} + +class InvalidLoginTokenException( + message: String, +) : Exception(message) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt new file mode 100644 index 000000000..949f2dddb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenInterface.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.auth.csrf + +import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Headers + +interface CsrfTokenInterface { + @Headers("Cache-Control: no-cache") + @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf") + fun getCsrfTokenCall(): Call +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt new file mode 100644 index 000000000..84481c918 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.auth.csrf + +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage +import javax.inject.Inject + +class LogoutClient + @Inject + constructor( + private val store: CommonsCookieStorage, + ) { + fun logout() = store.clear() + } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt new file mode 100644 index 000000000..8aa3d17a0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.auth.login + +interface LoginCallback { + fun success(loginResult: LoginResult) + + fun twoFactorPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) + + fun emailAuthPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) + + fun passwordResetPrompt(token: String?) + + fun error(caught: Throwable) +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt new file mode 100644 index 000000000..a653b8b55 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt @@ -0,0 +1,276 @@ +package fr.free.nrw.commons.auth.login + +import android.text.TextUtils +import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult +import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult +import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult +import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import timber.log.Timber +import java.io.IOException + +/** + * Responsible for making login related requests to the server. + */ +class LoginClient( + private val loginInterface: LoginInterface, +) { + private var tokenCall: Call? = null + private var loginCall: Call? = null + + /** + * userLanguage + * It holds the value of the user's device language code. + * For example, if user's device language is English it will hold En + * The value will be fetched when the user clicks Login Button in the LoginActivity + */ + private var userLanguage = "" + + private fun getLoginToken() = loginInterface.getLoginToken() + + fun request( + userName: String, + password: String, + cb: LoginCallback, + ) { + cancel() + + tokenCall = getLoginToken() + tokenCall!!.enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + login( + userName, + password, + null, + null, + null, + response.body()!!.query()!!.loginToken(), + userLanguage, + cb, + ) + } + + override fun onFailure( + call: Call, + caught: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.error(caught) + } + }, + ) + } + + fun login( + userName: String, + password: String, + retypedPassword: String?, + twoFactorCode: String?, + emailAuthCode: String?, + loginToken: String?, + userLanguage: String, + cb: LoginCallback, + ) { + this.userLanguage = userLanguage + + loginCall = + if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { + loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) + } else { + loginInterface.postLogIn( + userName, + password, + retypedPassword, + twoFactorCode, + emailAuthCode, + loginToken, + userLanguage, + true, + ) + } + + loginCall!!.enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + val loginResult = response.body()?.toLoginResult(password) + if (loginResult != null) { + if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { + // The server could do some transformations on user names, e.g. on some + // wikis is uppercases the first letter. + getExtendedInfo(loginResult.userName, loginResult, cb) + } else if ("UI" == loginResult.status) { + when (loginResult) { + is OAuthResult -> + cb.twoFactorPrompt( + loginResult, + LoginFailedException(loginResult.message), + loginToken, + ) + + is EmailAuthResult -> + cb.emailAuthPrompt( + loginResult, + LoginFailedException(loginResult.message), + loginToken + ) + + is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) + + is LoginResult.Result -> + cb.error( + LoginFailedException(loginResult.message), + ) + } + } else { + cb.error(LoginFailedException(loginResult.message)) + } + } else { + cb.error(IOException("Login failed. Unexpected response.")) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.error(t) + } + }, + ) + } + + fun doLogin( + username: String, + password: String, + lastLoginResult: LoginResult?, + twoFactorCode: String, + userLanguage: String, + loginCallback: LoginCallback, + ) { + getLoginToken().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) = if (response.isSuccessful) { + val loginToken = response.body()?.query()?.loginToken() + loginToken?.let { + login(username, password, null, + if (lastLoginResult is OAuthResult) twoFactorCode else null, + if (lastLoginResult is EmailAuthResult) twoFactorCode else null, + it, userLanguage, loginCallback) + } ?: run { + loginCallback.error(IOException("Failed to retrieve login token")) + } + } else { + loginCallback.error(IOException("Failed to retrieve login token")) + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + loginCallback.error(t) + } + }, + ) + } + + @Throws(Throwable::class) + fun loginBlocking( + userName: String, + password: String, + twoFactorCode: String? = null, + emailAuthCode: String? = null + ) { + val tokenResponse = getLoginToken().execute() + if (tokenResponse + .body() + ?.query() + ?.loginToken() + .isNullOrEmpty() + ) { + throw IOException("Unexpected response when getting login token.") + } + + val loginToken = tokenResponse.body()?.query()?.loginToken() + val tempLoginCall = + if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) { + loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) + } else { + loginInterface.postLogIn( + userName, + password, + null, + twoFactorCode, + emailAuthCode, + loginToken, + userLanguage, + true, + ) + } + + val response = tempLoginCall.execute() + val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.") + val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.") + + if ("UI" == loginResult.status) { + if (loginResult is OAuthResult || loginResult is EmailAuthResult) { + // TODO: Find a better way to boil up the warning about 2FA + throw LoginFailedException(loginResult.message) + } + throw LoginFailedException(loginResult.message) + } + + if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) { + throw LoginFailedException(loginResult.message) + } + } + + private fun getExtendedInfo( + userName: String, + loginResult: LoginResult, + cb: LoginCallback, + ) = loginInterface + .getUserInfo(userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response: MwQueryResponse? -> + loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 + loginResult.groups = + response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() + cb.success(loginResult) + }, { caught: Throwable -> + Timber.e(caught, "Login succeeded but getting group information failed. ") + cb.error(caught) + }) + + fun cancel() { + tokenCall?.let { + it.cancel() + tokenCall = null + } + + loginCall?.let { + it.cancel() + loginCall = null + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt new file mode 100644 index 000000000..fb5ad14c6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.auth.login + +class LoginFailedException( + message: String?, +) : Throwable(message) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt new file mode 100644 index 000000000..39cbf7c9f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt @@ -0,0 +1,48 @@ +package fr.free.nrw.commons.auth.login + +import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Observable +import retrofit2.Call +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query + +interface LoginInterface { + @Headers("Cache-Control: no-cache") + @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login") + fun getLoginToken(): Call + + @Headers("Cache-Control: no-cache") + @FormUrlEncoded + @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") + fun postLogIn( + @Field("username") user: String?, + @Field("password") pass: String?, + @Field("logintoken") token: String?, + @Field("uselang") userLanguage: String?, + @Field("loginreturnurl") url: String?, + ): Call + + @Headers("Cache-Control: no-cache") + @FormUrlEncoded + @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") + fun postLogIn( + @Field("username") user: String?, + @Field("password") pass: String?, + @Field("retype") retypedPass: String?, + @Field("OATHToken") twoFactorCode: String?, + @Field("token") emailAuthToken: String?, + @Field("logintoken") loginToken: String?, + @Field("uselang") userLanguage: String?, + @Field("logincontinue") loginContinue: Boolean, + ): Call + + @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") + fun getUserInfo( + @Query("ususers") userName: String, + ): Observable +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt new file mode 100644 index 000000000..0fb035eea --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.auth.login + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult +import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult +import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult +import fr.free.nrw.commons.auth.login.LoginResult.Result +import fr.free.nrw.commons.wikidata.mwapi.MwServiceError + +class LoginResponse { + @SerializedName("error") + val error: MwServiceError? = null + + @SerializedName("clientlogin") + private val clientLogin: ClientLogin? = null + + fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password) +} + +internal class ClientLogin { + private val status: String? = null + private val requests: List? = null + private val message: String? = null + + @SerializedName("username") + private val userName: String? = null + + fun toLoginResult(password: String): LoginResult { + var userMessage = message + if ("UI" == status) { + requests?.forEach { request -> + request.id()?.let { + if (it.endsWith("TOTPAuthenticationRequest")) { + return OAuthResult(status, userName, password, message) + } else if (it.endsWith("EmailAuthAuthenticationRequest")) { + return EmailAuthResult(status, userName, password, message) + } else if (it.endsWith("PasswordAuthenticationRequest")) { + return ResetPasswordResult(status, userName, password, message) + } + } + } + } else if ("PASS" != status && "FAIL" != status) { + // TODO: String resource -- Looks like needed for others in this class too + userMessage = "An unknown error occurred." + } + return Result(status ?: "", userName, password, userMessage) + } +} + +internal class Request { + private val id: String? = null + private val required: String? = null + private val provider: String? = null + private val account: String? = null + internal val fields: Map? = null + + fun id(): String? = id +} + +internal class RequestField { + private val type: String? = null + private val label: String? = null + internal val help: String? = null +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt new file mode 100644 index 000000000..99abaeeec --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt @@ -0,0 +1,40 @@ +package fr.free.nrw.commons.auth.login + +sealed class LoginResult( + val status: String, + val userName: String?, + val password: String?, + val message: String?, +) { + var userId = 0 + var groups = emptySet() + val pass: Boolean get() = "PASS" == status + + class Result( + status: String, + userName: String?, + password: String?, + message: String?, + ) : LoginResult(status, userName, password, message) + + class OAuthResult( + status: String, + userName: String?, + password: String?, + message: String?, + ) : LoginResult(status, userName, password, message) + + class EmailAuthResult( + status: String, + userName: String?, + password: String?, + message: String?, + ) : LoginResult(status, userName, password, message) + + class ResetPasswordResult( + status: String, + userName: String?, + password: String?, + message: String?, + ) : LoginResult(status, userName, password, message) +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt new file mode 100644 index 000000000..51f15b23c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkFragment.kt @@ -0,0 +1,98 @@ +package fr.free.nrw.commons.bookmarks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import fr.free.nrw.commons.contributions.ContributionController +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.FragmentBookmarksBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.theme.BaseActivity +import javax.inject.Inject +import javax.inject.Named + +class BookmarkFragment : CommonsDaggerSupportFragment() { + private var adapter: BookmarksPagerAdapter? = null + + @JvmField + var binding: FragmentBookmarksBinding? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + /** + * To check if the user is loggedIn or not. + */ + @JvmField + @Inject + @Named("default_preferences") + var applicationKvStore: JsonKvStore? = null + + fun setScroll(canScroll: Boolean) { + binding?.let { + it.viewPagerBookmarks.canScroll = canScroll + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + binding = FragmentBookmarksBinding.inflate(inflater, container, false) + + // Activity can call methods in the fragment by acquiring a + // reference to the Fragment from FragmentManager, using findFragmentById() + val supportFragmentManager = childFragmentManager + + adapter = BookmarksPagerAdapter( + supportFragmentManager, requireContext(), + applicationKvStore!!.getBoolean("login_skipped") + ) + binding!!.viewPagerBookmarks.adapter = adapter + binding!!.tabLayout.setupWithViewPager(binding!!.viewPagerBookmarks) + + (requireActivity() as MainActivity).showTabs() + (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false) + + setupTabLayout() + return binding!!.root + } + + /** + * This method sets up the tab layout. If the adapter has only one element it sets the + * visibility of tabLayout to gone. + */ + fun setupTabLayout() { + binding!!.tabLayout.visibility = View.VISIBLE + if (adapter!!.count == 1) { + binding!!.tabLayout.visibility = View.GONE + } + } + + + fun onBackPressed() { + if (((adapter!!.getItem(binding!!.tabLayout.selectedTabPosition)) as BookmarkListRootFragment).backPressed()) { + // The event is handled internally by the adapter , no further action required. + return + } + + // Event is not handled by the adapter ( performed back action ) change action bar. + (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false) + } + + override fun onDestroy() { + super.onDestroy() + binding = null + } + + companion object { + fun newInstance(): BookmarkFragment = BookmarkFragment().apply { + retainInstance = true + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt new file mode 100644 index 000000000..a9ed33abc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.kt @@ -0,0 +1,226 @@ +package fr.free.nrw.commons.bookmarks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.AdapterView.OnItemClickListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment +import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment +import fr.free.nrw.commons.category.CategoryImagesCallback +import fr.free.nrw.commons.category.GridViewAdapter +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment.Companion.newInstance +import fr.free.nrw.commons.media.MediaDetailProvider +import fr.free.nrw.commons.navtab.NavTab +import timber.log.Timber + +class BookmarkListRootFragment : CommonsDaggerSupportFragment, + FragmentManager.OnBackStackChangedListener, MediaDetailProvider, OnItemClickListener, + CategoryImagesCallback { + private var mediaDetails: MediaDetailPagerFragment? = null + private val bookmarkLocationsFragment: BookmarkLocationsFragment? = null + var listFragment: Fragment? = null + private var bookmarksPagerAdapter: BookmarksPagerAdapter? = null + + var binding: FragmentFeaturedRootBinding? = null + + constructor() + + constructor(bundle: Bundle, bookmarksPagerAdapter: BookmarksPagerAdapter) { + val title = bundle.getString("categoryName") + val order = bundle.getInt("order") + val orderItem = bundle.getInt("orderItem") + + when (order) { + 0 -> listFragment = BookmarkPicturesFragment() + 1 -> listFragment = BookmarkLocationsFragment() + 3 -> listFragment = BookmarkCategoriesFragment() + } + if (orderItem == 2) { + listFragment = BookmarkItemsFragment() + } + + val featuredArguments = Bundle() + featuredArguments.putString("categoryName", title) + listFragment!!.setArguments(featuredArguments) + this.bookmarksPagerAdapter = bookmarksPagerAdapter + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreate(savedInstanceState) + binding = FragmentFeaturedRootBinding.inflate(inflater, container, false) + return binding!!.getRoot() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState == null) { + setFragment(listFragment!!, mediaDetails) + } + } + + fun setFragment(fragment: Fragment, otherFragment: Fragment?) { + if (fragment.isAdded() && otherFragment != null) { + getChildFragmentManager() + .beginTransaction() + .hide(otherFragment) + .show(fragment) + .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") + .commit() + getChildFragmentManager().executePendingTransactions() + } else if (fragment.isAdded() && otherFragment == null) { + getChildFragmentManager() + .beginTransaction() + .show(fragment) + .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") + .commit() + getChildFragmentManager().executePendingTransactions() + } else if (!fragment.isAdded() && otherFragment != null) { + getChildFragmentManager() + .beginTransaction() + .hide(otherFragment) + .add(R.id.explore_container, fragment) + .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") + .commit() + getChildFragmentManager().executePendingTransactions() + } else if (!fragment.isAdded()) { + getChildFragmentManager() + .beginTransaction() + .replace(R.id.explore_container, fragment) + .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") + .commit() + getChildFragmentManager().executePendingTransactions() + } + } + + fun removeFragment(fragment: Fragment) { + getChildFragmentManager() + .beginTransaction() + .remove(fragment) + .commit() + getChildFragmentManager().executePendingTransactions() + } + + override fun onMediaClicked(position: Int) { + Timber.d("on media clicked") + /*container.setVisibility(View.VISIBLE); + ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); + mediaDetails = new MediaDetailPagerFragment(false, true, position); + setFragment(mediaDetails, bookmarkPicturesFragment);*/ + } + + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * + * @param i It is the index of which media object is to be returned which is same as current + * index of viewPager. + * @return Media Object + */ + override fun getMediaAtPosition(i: Int): Media? = + bookmarksPagerAdapter!!.mediaAdapter?.getItem(i) as Media? + + /** + * This method is called on from getCount of MediaDetailPagerFragment The viewpager will contain + * same number of media items as that of media elements in adapter. + * + * @return Total Media count in the adapter + */ + override fun getTotalMediaCount(): Int = + bookmarksPagerAdapter!!.mediaAdapter?.count ?: 0 + + override fun getContributionStateAt(position: Int): Int? { + return null + } + + /** + * Reload media detail fragment once media is nominated + * + * @param index item position that has been nominated + */ + override fun refreshNominatedMedia(index: Int) { + if (mediaDetails != null && !listFragment!!.isVisible()) { + removeFragment(mediaDetails!!) + mediaDetails = newInstance(false, true) + (parentFragment as BookmarkFragment).setScroll(false) + setFragment(mediaDetails!!, listFragment) + mediaDetails!!.showImage(index) + } + } + + /** + * This method is called on success of API call for featured images or mobile uploads. The + * viewpager will notified that number of items have changed. + */ + override fun viewPagerNotifyDataSetChanged() { + if (mediaDetails != null) { + mediaDetails!!.notifyDataSetChanged() + } + } + + fun backPressed(): Boolean { + //check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException + if (mediaDetails != null) { + if (mediaDetails!!.isVisible()) { + // todo add get list fragment + (parentFragment as BookmarkFragment).setupTabLayout() + val removed: ArrayList = mediaDetails!!.removedItems + removeFragment(mediaDetails!!) + (parentFragment as BookmarkFragment).setScroll(true) + setFragment(listFragment!!, mediaDetails) + (requireActivity() as MainActivity).showTabs() + if (listFragment is BookmarkPicturesFragment) { + val adapter = ((listFragment as BookmarkPicturesFragment) + .getAdapter() as GridViewAdapter?) + val i: MutableIterator<*> = removed.iterator() + while (i.hasNext()) { + adapter!!.remove(adapter.getItem(i.next() as Int)) + } + mediaDetails!!.clearRemoved() + } + } else { + moveToContributionsFragment() + } + } else { + moveToContributionsFragment() + } + // notify mediaDetails did not handled the backPressed further actions required. + return false + } + + fun moveToContributionsFragment() { + (requireActivity() as MainActivity).setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + (requireActivity() as MainActivity).showTabs() + } + + override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + Timber.d("on media clicked") + binding!!.exploreContainer.visibility = View.VISIBLE + (parentFragment as BookmarkFragment).binding!!.tabLayout.setVisibility(View.GONE) + mediaDetails = newInstance(false, true) + (parentFragment as BookmarkFragment).setScroll(false) + setFragment(mediaDetails!!, listFragment) + mediaDetails!!.showImage(position) + } + + override fun onBackStackChanged() = Unit + + override fun onDestroy() { + super.onDestroy() + binding = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt new file mode 100644 index 000000000..e0ade52fe --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.bookmarks + +import androidx.fragment.app.Fragment + +data class BookmarkPages ( + val page: Fragment? = null, + val title: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt new file mode 100644 index 000000000..a7cbf0e68 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.kt @@ -0,0 +1,82 @@ +package fr.free.nrw.commons.bookmarks + +import android.content.Context +import android.widget.ListAdapter +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import fr.free.nrw.commons.R +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment + +class BookmarksPagerAdapter internal constructor( + fm: FragmentManager, context: Context, onlyPictures: Boolean +) : FragmentPagerAdapter(fm) { + private val pages = mutableListOf() + + /** + * Default Constructor + * @param fm + * @param context + * @param onlyPictures is true if the fragment requires only BookmarkPictureFragment + * (i.e. when no user is logged in). + */ + init { + pages.add( + BookmarkPages( + BookmarkListRootFragment( + bundleOf( + "categoryName" to context.getString(R.string.title_page_bookmarks_pictures), + "order" to 0 + ), this + ), context.getString(R.string.title_page_bookmarks_pictures) + ) + ) + if (!onlyPictures) { + // if onlyPictures is false we also add the location fragment. + val locationBundle = bundleOf( + "categoryName" to context.getString(R.string.title_page_bookmarks_locations), + "order" to 1 + ) + + pages.add( + BookmarkPages( + BookmarkListRootFragment(locationBundle, this), + context.getString(R.string.title_page_bookmarks_locations) + ) + ) + + locationBundle.putInt("orderItem", 2) + pages.add( + BookmarkPages( + BookmarkListRootFragment(locationBundle, this), + context.getString(R.string.title_page_bookmarks_items) + ) + ) + } + pages.add( + BookmarkPages( + BookmarkListRootFragment( + bundleOf( + "categoryName" to context.getString(R.string.title_page_bookmarks_categories), + "order" to 3 + ), this), + context.getString(R.string.title_page_bookmarks_categories) + ) + ) + notifyDataSetChanged() + } + + override fun getItem(position: Int): Fragment = pages[position].page!! + + override fun getCount(): Int = pages.size + + override fun getPageTitle(position: Int): CharSequence? = pages[position].title + + /** + * Return the Adapter used to display the picture gridview + * @return adapter + */ + val mediaAdapter: ListAdapter? + get() = (((pages[0].page as BookmarkListRootFragment).listFragment) as BookmarkPicturesFragment).getAdapter() +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt new file mode 100644 index 000000000..71a2d1ec9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.bookmarks.category + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Bookmark categories dao + * + * @constructor Create empty Bookmark categories dao + */ +@Dao +interface BookmarkCategoriesDao { + + /** + * Insert or Delete category bookmark into DB + * + * @param bookmarksCategoryModal + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal) + + + /** + * Delete category bookmark from DB + * + * @param bookmarksCategoryModal + */ + @Delete + suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal) + + /** + * Checks if given category exist in DB + * + * @param categoryName + * @return + */ + @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)") + suspend fun doesExist(categoryName: String): Boolean + + /** + * Get all categories + * + * @return + */ + @Query("SELECT * FROM bookmarks_categories") + fun getAllCategories(): Flow> + +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt new file mode 100644 index 000000000..ef5bc613d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt @@ -0,0 +1,143 @@ +package fr.free.nrw.commons.bookmarks.category + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.android.support.DaggerFragment +import fr.free.nrw.commons.R +import fr.free.nrw.commons.category.CategoryDetailsActivity +import javax.inject.Inject + +/** + * Tab fragment to show list of bookmarked Categories + */ +class BookmarkCategoriesFragment : DaggerFragment() { + + @Inject + lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme( + primary = colorResource(R.color.primaryDarkColor), + surface = colorResource(R.color.main_background_dark), + background = colorResource(R.color.main_background_dark) + ) else lightColorScheme( + primary = colorResource(R.color.primaryColor), + surface = colorResource(R.color.main_background_light), + background = colorResource(R.color.main_background_light) + ) + ) { + val listOfBookmarks by bookmarkCategoriesDao.getAllCategories() + .collectAsStateWithLifecycle(initialValue = emptyList()) + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + if (listOfBookmarks.isEmpty()) { + Text( + text = stringResource(R.string.bookmark_empty), + style = MaterialTheme.typography.bodyMedium, + color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF) + else Color( + 0x8A000000 + ) + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = listOfBookmarks) { bookmarkItem -> + CategoryItem( + categoryName = bookmarkItem.categoryName, + onClick = { + val categoryDetailsIntent = Intent( + requireContext(), + CategoryDetailsActivity::class.java + ).putExtra("categoryName", it) + startActivity(categoryDetailsIntent) + } + ) + } + } + } + } + } + } + } + } + } + + + @Composable + fun CategoryItem( + modifier: Modifier = Modifier, + onClick: (String) -> Unit, + categoryName: String + ) { + Row(modifier = modifier.clickable { + onClick(categoryName) + }) { + ListItem( + leadingContent = { + Image( + modifier = Modifier.size(48.dp), + painter = painterResource(R.drawable.commons), + contentDescription = null + ) + }, + headlineContent = { + Text( + text = categoryName, + maxLines = 2, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + ) + } + } + + @Preview + @Composable + private fun CategoryItemPreview() { + CategoryItem( + onClick = {}, + categoryName = "Test Category" + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt new file mode 100644 index 000000000..ab679611f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.bookmarks.category + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Data class representing bookmarked category in DB + * + * @property categoryName + * @constructor Create empty Bookmarks category modal + */ +@Entity(tableName = "bookmarks_categories") +data class BookmarksCategoryModal( + @PrimaryKey val categoryName: String +) diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt new file mode 100644 index 000000000..4233d9508 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.bookmarks.items + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem + +/** + * Helps to inflate Wikidata Items into Items tab + */ +class BookmarkItemsAdapter( + val list: List, + val context: Context, +) : RecyclerView.Adapter() { + class BookmarkItemViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) + var description: TextView = itemView.findViewById(R.id.description) + var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) + var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BookmarkItemViewHolder { + val v: View = + LayoutInflater + .from(context) + .inflate(R.layout.item_depictions, parent, false) + return BookmarkItemViewHolder(v) + } + + override fun onBindViewHolder( + holder: BookmarkItemViewHolder, + position: Int, + ) { + val depictedItem = list[position] + holder.depictsLabel.text = depictedItem.name + holder.description.text = depictedItem.description + + if (depictedItem.imageUrl?.isNotBlank() == true) { + holder.depictsImage.setImageURI(depictedItem.imageUrl) + } else { + holder.depictsImage.setActualImageResource(R.drawable.ic_wikidata_logo_24dp) + } + holder.layout.setOnClickListener { + WikidataItemDetailsActivity.startYourself(context, depictedItem) + } + } + + override fun getItemCount(): Int = list.size +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt new file mode 100644 index 000000000..c532ed3cc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsContentProvider.kt @@ -0,0 +1,101 @@ +package fr.free.nrw.commons.bookmarks.items + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteQueryBuilder +import android.net.Uri +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.TABLE_NAME +import fr.free.nrw.commons.di.CommonsDaggerContentProvider +import androidx.core.net.toUri +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_ID + +/** + * Handles private storage for bookmarked items + */ +class BookmarkItemsContentProvider : CommonsDaggerContentProvider() { + override fun getType(uri: Uri): String? = null + + /** + * Queries the SQLite database for the bookmark items + * @param uri : contains the uri for bookmark items + * @param projection : contains the all fields of the table + * @param selection : handles Where + * @param selectionArgs : the condition of Where clause + * @param sortOrder : ascending or descending + */ + override fun query( + uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String? + ): Cursor { + val queryBuilder = SQLiteQueryBuilder().apply { + tables = TABLE_NAME + } + + return queryBuilder.query( + requireDb(), projection, selection, + selectionArgs, null, null, sortOrder + ).apply { + setNotificationUri(context?.contentResolver, uri) + } + } + + /** + * Handles the update query of local SQLite Database + * @param uri : contains the uri for bookmark items + * @param contentValues : new values to be entered to db + * @param selection : handles Where + * @param selectionArgs : the condition of Where clause + */ + override fun update( + uri: Uri, contentValues: ContentValues?, + selection: String?, selectionArgs: Array? + ): Int { + val rowsUpdated: Int + if (selection.isNullOrEmpty()) { + val id = uri.lastPathSegment!!.toInt() + rowsUpdated = requireDb().update( + TABLE_NAME, + contentValues, + "$COLUMN_ID = ?", + arrayOf(id.toString()) + ) + } else { + throw IllegalArgumentException( + "Parameter `selection` should be empty when updating an ID" + ) + } + + context?.contentResolver?.notifyChange(uri, null) + return rowsUpdated + } + + /** + * Handles the insertion of new bookmark items record to local SQLite Database + */ + override fun insert(uri: Uri, contentValues: ContentValues?): Uri? { + val id = requireDb().insert(TABLE_NAME, null, contentValues) + context?.contentResolver?.notifyChange(uri, null) + return "$BASE_URI/$id".toUri() + } + + + /** + * Handles the deletion of new bookmark items record to local SQLite Database + */ + override fun delete(uri: Uri, s: String?, strings: Array?): Int { + val rows: Int = requireDb().delete( + TABLE_NAME, + "$COLUMN_ID = ?", + arrayOf(uri.lastPathSegment) + ) + context?.contentResolver?.notifyChange(uri, null) + return rows + } + + companion object { + private const val BASE_PATH = "bookmarksItems" + val BASE_URI: Uri = "content://${BuildConfig.BOOKMARK_ITEMS_AUTHORITY}/$BASE_PATH".toUri() + fun uriForName(id: String) = "$BASE_URI/$id".toUri() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt new file mode 100644 index 000000000..d1a9ef785 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsController.kt @@ -0,0 +1,23 @@ +package fr.free.nrw.commons.bookmarks.items + +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Handles loading bookmarked items from Database + */ +@Singleton +class BookmarkItemsController @Inject constructor() { + @JvmField + @Inject + var bookmarkItemsDao: BookmarkItemsDao? = null + + /** + * Load from DB the bookmarked items + * @return a list of DepictedItem objects. + */ + fun loadFavoritesItems(): List { + return bookmarkItemsDao?.getAllBookmarksItems() ?: emptyList() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt new file mode 100644 index 000000000..e21e1ac8f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.kt @@ -0,0 +1,203 @@ +package fr.free.nrw.commons.bookmarks.items + +import android.annotation.SuppressLint +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.os.RemoteException +import androidx.core.content.contentValuesOf +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider.Companion.BASE_URI +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider.Companion.uriForName +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_DESCRIPTION_LIST +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_NAME_LIST +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_CATEGORIES_THUMBNAIL_LIST +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_DESCRIPTION +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_ID +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_IMAGE +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_INSTANCE_LIST +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_IS_SELECTED +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable.COLUMN_NAME +import fr.free.nrw.commons.category.CategoryItem +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import fr.free.nrw.commons.utils.arrayToString +import fr.free.nrw.commons.utils.getString +import fr.free.nrw.commons.utils.getStringArray +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Handles database operations for bookmarked items + */ +@Singleton +class BookmarkItemsDao @Inject constructor( + @param:Named("bookmarksItem") private val clientProvider: Provider +) { + /** + * Find all persisted items bookmarks on database + * @return list of bookmarks + */ + fun getAllBookmarksItems(): List { + val items: MutableList = mutableListOf() + val db = clientProvider.get() + try { + db.query( + BASE_URI, + BookmarkItemsTable.ALL_FIELDS, + null, + arrayOf(), + null + ).use { cursor -> + while (cursor != null && cursor.moveToNext()) { + items.add(fromCursor(cursor)) + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + return items + } + + + /** + * Look for a bookmark in database and in order to insert or delete it + * @param depictedItem : Bookmark object + * @return boolean : is bookmark now favorite ? + */ + fun updateBookmarkItem(depictedItem: DepictedItem): Boolean { + val bookmarkExists = findBookmarkItem(depictedItem.id) + if (bookmarkExists) { + deleteBookmarkItem(depictedItem) + } else { + addBookmarkItem(depictedItem) + } + return !bookmarkExists + } + + /** + * Add a Bookmark to database + * @param depictedItem : Bookmark to add + */ + private fun addBookmarkItem(depictedItem: DepictedItem) { + val db = clientProvider.get() + try { + db.insert(BASE_URI, toContentValues(depictedItem)) + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Delete a bookmark from database + * @param depictedItem : Bookmark to delete + */ + private fun deleteBookmarkItem(depictedItem: DepictedItem) { + val db = clientProvider.get() + try { + db.delete(uriForName(depictedItem.id), null, null) + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Find a bookmark from database based on its name + * @param depictedItemID : Bookmark to find + * @return boolean : is bookmark in database ? + */ + fun findBookmarkItem(depictedItemID: String?): Boolean { + if (depictedItemID == null) { //Avoiding NPE's + return false + } + val db = clientProvider.get() + try { + db.query( + BASE_URI, + BookmarkItemsTable.ALL_FIELDS, + COLUMN_ID + "=?", + arrayOf(depictedItemID), + null + ).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + return true + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + return false + } + + /** + * Recives real data from cursor + * @param cursor : Object for storing database data + * @return DepictedItem + */ + @SuppressLint("Range") + fun fromCursor(cursor: Cursor) = with(cursor) { + var name = getString(COLUMN_NAME) + if (name == null) { + name = "" + } + + var id = getString(COLUMN_ID) + if (id == null) { + id = "" + } + + DepictedItem( + name, + getString(COLUMN_DESCRIPTION), + getString(COLUMN_IMAGE), + getStringArray(COLUMN_INSTANCE_LIST), + convertToCategoryItems( + getStringArray(COLUMN_CATEGORIES_NAME_LIST), + getStringArray(COLUMN_CATEGORIES_DESCRIPTION_LIST), + getStringArray(COLUMN_CATEGORIES_THUMBNAIL_LIST) + ), + getString(COLUMN_IS_SELECTED).toBoolean(), + id + ) + } + + private fun convertToCategoryItems( + categoryNameList: List, + categoryDescriptionList: List, + categoryThumbnailList: List + ): List = categoryNameList.mapIndexed { index, name -> + CategoryItem( + name = name, + description = categoryDescriptionList.getOrNull(index), + thumbnail = categoryThumbnailList.getOrNull(index), + isSelected = false + ) + } + + /** + * Takes data from DepictedItem and create a content value object + * @param depictedItem depicted item + * @return ContentValues + */ + private fun toContentValues(depictedItem: DepictedItem): ContentValues { + return contentValuesOf( + COLUMN_NAME to depictedItem.name, + COLUMN_DESCRIPTION to depictedItem.description, + COLUMN_IMAGE to depictedItem.imageUrl, + COLUMN_INSTANCE_LIST to arrayToString(depictedItem.instanceOfs), + COLUMN_CATEGORIES_NAME_LIST to arrayToString(depictedItem.commonsCategories.map { it.name }), + COLUMN_CATEGORIES_DESCRIPTION_LIST to arrayToString(depictedItem.commonsCategories.map { it.description }), + COLUMN_CATEGORIES_THUMBNAIL_LIST to arrayToString(depictedItem.commonsCategories.map { it.thumbnail }), + COLUMN_IS_SELECTED to depictedItem.isSelected, + COLUMN_ID to depictedItem.id, + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt new file mode 100644 index 000000000..aa9dcccc0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsFragment.kt @@ -0,0 +1,62 @@ +package fr.free.nrw.commons.bookmarks.items + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import dagger.android.support.DaggerFragment +import fr.free.nrw.commons.R +import fr.free.nrw.commons.databinding.FragmentBookmarksItemsBinding +import javax.inject.Inject + +/** + * Tab fragment to show list of bookmarked Wikidata Items + */ +class BookmarkItemsFragment : DaggerFragment() { + private var binding: FragmentBookmarksItemsBinding? = null + + @JvmField + @Inject + var controller: BookmarkItemsController? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentBookmarksItemsBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initList(requireContext()) + } + + override fun onResume() { + super.onResume() + initList(requireContext()) + } + + /** + * Get list of DepictedItem and sets to the adapter + * @param context context + */ + private fun initList(context: Context) { + val depictItems = controller!!.loadFavoritesItems() + binding!!.listView.adapter = BookmarkItemsAdapter(depictItems, context) + binding!!.loadingImagesProgressBar.visibility = View.GONE + if (depictItems.isEmpty()) { + binding!!.statusMessage.setText(R.string.bookmark_empty) + binding!!.statusMessage.visibility = View.VISIBLE + } else { + binding!!.statusMessage.visibility = View.GONE + } + } + + override fun onDestroy() { + super.onDestroy() + binding = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt new file mode 100644 index 000000000..b1b03c71b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsTable.kt @@ -0,0 +1,90 @@ +package fr.free.nrw.commons.bookmarks.items + +import android.database.sqlite.SQLiteDatabase + +/** + * Table of bookmarksItems data + */ +object BookmarkItemsTable { + const val TABLE_NAME = "bookmarksItems" + const val COLUMN_NAME = "item_name" + const val COLUMN_DESCRIPTION = "item_description" + const val COLUMN_IMAGE = "item_image_url" + const val COLUMN_INSTANCE_LIST = "item_instance_of" + const val COLUMN_CATEGORIES_NAME_LIST = "item_name_categories" + const val COLUMN_CATEGORIES_DESCRIPTION_LIST = "item_description_categories" + const val COLUMN_CATEGORIES_THUMBNAIL_LIST = "item_thumbnail_categories" + const val COLUMN_IS_SELECTED = "item_is_selected" + const val COLUMN_ID = "item_id" + + val ALL_FIELDS = arrayOf( + COLUMN_NAME, + COLUMN_DESCRIPTION, + COLUMN_IMAGE, + COLUMN_INSTANCE_LIST, + COLUMN_CATEGORIES_NAME_LIST, + COLUMN_CATEGORIES_DESCRIPTION_LIST, + COLUMN_CATEGORIES_THUMBNAIL_LIST, + COLUMN_IS_SELECTED, + COLUMN_ID + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + val CREATE_TABLE_STATEMENT = + """CREATE TABLE $TABLE_NAME ( + $COLUMN_NAME STRING, + $COLUMN_DESCRIPTION STRING, + $COLUMN_IMAGE STRING, + $COLUMN_INSTANCE_LIST STRING, + $COLUMN_CATEGORIES_NAME_LIST STRING, + $COLUMN_CATEGORIES_DESCRIPTION_LIST STRING, + $COLUMN_CATEGORIES_THUMBNAIL_LIST STRING, + $COLUMN_IS_SELECTED STRING, + $COLUMN_ID STRING PRIMARY KEY + );""".trimIndent() + + /** + * Creates table + * + * @param db SQLiteDatabase + */ + fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_TABLE_STATEMENT) + } + + /** + * Deletes database + * + * @param db SQLiteDatabase + */ + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + /** + * Updates database + * + * @param db SQLiteDatabase + * @param from starting + * @param to end + */ + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) { + return + } + + if (from < 18) { + // doesn't exist yet + onUpdate(db, from + 1, to) + return + } + + if (from == 18) { + // table added in version 19 + onCreate(db) + onUpdate(db, from + 1, to) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt new file mode 100644 index 000000000..81ec80214 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.bookmarks.locations + +import fr.free.nrw.commons.nearby.Place +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BookmarkLocationsController @Inject constructor( + private val bookmarkLocationDao: BookmarkLocationsDao +) { + + /** + * Load bookmarked locations from the database. + * @return a list of Place objects. + */ + suspend fun loadFavoritesLocations(): List = + bookmarkLocationDao.getAllBookmarksLocationsPlace() +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt new file mode 100644 index 000000000..2fa65b2d9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt @@ -0,0 +1,65 @@ +package fr.free.nrw.commons.bookmarks.locations + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import fr.free.nrw.commons.nearby.NearbyController +import fr.free.nrw.commons.nearby.Place + +/** + * DAO for managing bookmark locations in the database. + */ +@Dao +abstract class BookmarkLocationsDao { + + /** + * Adds or updates a bookmark location in the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun addBookmarkLocation(bookmarkLocation: BookmarksLocations) + + /** + * Fetches all bookmark locations from the database. + */ + @Query("SELECT * FROM bookmarks_locations") + abstract suspend fun getAllBookmarksLocations(): List + + /** + * Checks if a bookmark location exists by name. + */ + @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_locations WHERE location_name = :name)") + abstract suspend fun findBookmarkLocation(name: String): Boolean + + /** + * Deletes a bookmark location from the database. + */ + @Delete + abstract suspend fun deleteBookmarkLocation(bookmarkLocation: BookmarksLocations) + + /** + * Adds or removes a bookmark location and updates markers. + * @return `true` if added, `false` if removed. + */ + suspend fun updateBookmarkLocation(bookmarkLocation: Place): Boolean { + val exists = findBookmarkLocation(bookmarkLocation.name) + + if (exists) { + deleteBookmarkLocation(bookmarkLocation.toBookmarksLocations()) + NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, false) + } else { + addBookmarkLocation(bookmarkLocation.toBookmarksLocations()) + NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, true) + } + + return !exists + } + + /** + * Fetches all bookmark locations as `Place` objects. + */ + suspend fun getAllBookmarksLocationsPlace(): List { + return getAllBookmarksLocations().map { it.toPlace() } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt new file mode 100644 index 000000000..f10e02ebc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt @@ -0,0 +1,151 @@ +package fr.free.nrw.commons.bookmarks.locations + +import android.Manifest.permission +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.android.support.DaggerFragment +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.ContributionController +import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions +import fr.free.nrw.commons.nearby.fragments.PlaceAdapter +import kotlinx.coroutines.launch +import javax.inject.Inject + + +class BookmarkLocationsFragment : DaggerFragment() { + + private var binding: FragmentBookmarksLocationsBinding? = null + + @Inject lateinit var controller: BookmarkLocationsController + @Inject lateinit var contributionController: ContributionController + @Inject lateinit var bookmarkLocationDao: BookmarkLocationsDao + @Inject lateinit var commonPlaceClickActions: CommonPlaceClickActions + + private lateinit var inAppCameraLocationPermissionLauncher: + ActivityResultLauncher> + private lateinit var adapter: PlaceAdapter + + private val cameraPickLauncherForResult = + registerForActivityResult(StartActivityForResult()) { result -> + contributionController.handleActivityResultWithCallback( + requireActivity() + ) { callbacks -> + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } + } + + private val galleryPickLauncherForResult = + registerForActivityResult(StartActivityForResult()) { result -> + contributionController.handleActivityResultWithCallback( + requireActivity() + ) { callbacks -> + contributionController.onPictureReturnedFromGallery( + result, + requireActivity(), + callbacks + ) + } + } + + companion object { + fun newInstance(): BookmarkLocationsFragment { + return BookmarkLocationsFragment() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.loadingImagesProgressBar?.visibility = View.VISIBLE + binding?.listView?.layoutManager = LinearLayoutManager(context) + + inAppCameraLocationPermissionLauncher = + registerForActivityResult(RequestMultiplePermissions()) { result -> + val areAllGranted = result.values.all { it } + + if (areAllGranted) { + contributionController.locationPermissionCallback?.onLocationPermissionGranted() + } else { + if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { + contributionController.handleShowRationaleFlowCameraLocation( + requireActivity(), + inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult + ) + } else { + contributionController.locationPermissionCallback + ?.onLocationPermissionDenied( + getString(R.string.in_app_camera_location_permission_denied) + ) + } + } + } + + adapter = PlaceAdapter( + bookmarkLocationDao, + lifecycleScope, + { }, + { place, _ -> + adapter.remove(place) + }, + commonPlaceClickActions, + inAppCameraLocationPermissionLauncher, + galleryPickLauncherForResult, + cameraPickLauncherForResult + ) + binding?.listView?.adapter = adapter + } + + override fun onResume() { + super.onResume() + initList() + } + + fun initList() { + var places: List + if(view != null) { + viewLifecycleOwner.lifecycleScope.launch { + places = controller.loadFavoritesLocations() + updateUIList(places) + } + } + } + + private fun updateUIList(places: List) { + adapter.items = places + binding?.loadingImagesProgressBar?.visibility = View.GONE + if (places.isEmpty()) { + binding?.statusMessage?.text = getString(R.string.bookmark_empty) + binding?.statusMessage?.visibility = View.VISIBLE + } else { + binding?.statusMessage?.visibility = View.GONE + } + } + + override fun onDestroy() { + super.onDestroy() + // Make sure to null out the binding to avoid memory leaks + binding = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt new file mode 100644 index 000000000..b22723c0f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.bookmarks.locations + +import androidx.lifecycle.ViewModel +import fr.free.nrw.commons.nearby.Place +import kotlinx.coroutines.flow.Flow + +class BookmarkLocationsViewModel( + private val bookmarkLocationsDao: BookmarkLocationsDao +): ViewModel() { + +// fun getAllBookmarkLocations(): List { +// return bookmarkLocationsDao.getAllBookmarksLocationsPlace() +// } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt new file mode 100644 index 000000000..66d670169 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt @@ -0,0 +1,72 @@ +package fr.free.nrw.commons.bookmarks.locations + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Label +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.Sitelinks + +@Entity(tableName = "bookmarks_locations") +data class BookmarksLocations( + @PrimaryKey @ColumnInfo(name = "location_name") val locationName: String, + @ColumnInfo(name = "location_language") val locationLanguage: String, + @ColumnInfo(name = "location_description") val locationDescription: String, + @ColumnInfo(name = "location_lat") val locationLat: Double, + @ColumnInfo(name = "location_long") val locationLong: Double, + @ColumnInfo(name = "location_category") val locationCategory: String, + @ColumnInfo(name = "location_label_text") val locationLabelText: String, + @ColumnInfo(name = "location_label_icon") val locationLabelIcon: Int?, + @ColumnInfo(name = "location_image_url") val locationImageUrl: String, + @ColumnInfo(name = "location_wikipedia_link") val locationWikipediaLink: String, + @ColumnInfo(name = "location_wikidata_link") val locationWikidataLink: String, + @ColumnInfo(name = "location_commons_link") val locationCommonsLink: String, + @ColumnInfo(name = "location_pic") val locationPic: String, + @ColumnInfo(name = "location_exists") val locationExists: Boolean +) + +fun BookmarksLocations.toPlace(): Place { + val location = LatLng( + locationLat, + locationLong, + 1F + ) + + val builder = Sitelinks.Builder().apply { + setWikipediaLink(locationWikipediaLink) + setWikidataLink(locationWikidataLink) + setCommonsLink(locationCommonsLink) + } + + return Place( + locationLanguage, + locationName, + Label.fromText(locationLabelText), + locationDescription, + location, + locationCategory, + builder.build(), + locationPic, + locationExists + ) +} + +fun Place.toBookmarksLocations(): BookmarksLocations { + return BookmarksLocations( + locationName = name, + locationLanguage = language, + locationDescription = longDescription, + locationCategory = category, + locationLat = location.latitude, + locationLong = location.longitude, + locationLabelText = label?.text ?: "", + locationLabelIcon = label?.icon, + locationImageUrl = pic, + locationWikipediaLink = siteLinks.wikipediaLink.toString(), + locationWikidataLink = siteLinks.wikidataLink.toString(), + locationCommonsLink = siteLinks.commonsLink.toString(), + locationPic = pic, + locationExists = exists + ) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt new file mode 100644 index 000000000..630889c01 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.bookmarks.models + +import android.net.Uri + +class Bookmark( + mediaName: String?, + mediaCreator: String?, + /** + * Gets or Sets the content URI - marking this bookmark as already saved in the database + * @return content URI + * contentUri the content URI + */ + var contentUri: Uri?, +) { + /** + * Gets the media name + * @return the media name + */ + val mediaName: String = mediaName ?: "" + + /** + * Gets media creator + * @return creator name + */ + val mediaCreator: String = mediaCreator ?: "" +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt new file mode 100644 index 000000000..a47eed8ca --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesContentProvider.kt @@ -0,0 +1,100 @@ +package fr.free.nrw.commons.bookmarks.pictures + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteQueryBuilder +import android.net.Uri +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.di.CommonsDaggerContentProvider +import androidx.core.net.toUri +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_MEDIA_NAME +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.TABLE_NAME + +/** + * Handles private storage for Bookmark pictures + */ +class BookmarkPicturesContentProvider : CommonsDaggerContentProvider() { + override fun getType(uri: Uri): String? = null + + /** + * Queries the SQLite database for the bookmark pictures + * @param uri : contains the uri for bookmark pictures + * @param projection + * @param selection : handles Where + * @param selectionArgs : the condition of Where clause + * @param sortOrder : ascending or descending + */ + override fun query( + uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String? + ): Cursor { + val queryBuilder = SQLiteQueryBuilder().apply { + tables = TABLE_NAME + } + + val cursor = queryBuilder.query( + requireDb(), projection, selection, + selectionArgs, null, null, sortOrder + ) + cursor.setNotificationUri(context?.contentResolver, uri) + + return cursor + } + + /** + * Handles the update query of local SQLite Database + * @param uri : contains the uri for bookmark pictures + * @param contentValues : new values to be entered to db + * @param selection : handles Where + * @param selectionArgs : the condition of Where clause + */ + override fun update( + uri: Uri, contentValues: ContentValues?, selection: String?, + selectionArgs: Array? + ): Int { + val rowsUpdated: Int + if (selection.isNullOrEmpty()) { + val id = uri.lastPathSegment!!.toInt() + rowsUpdated = requireDb().update( + TABLE_NAME, + contentValues, + "$COLUMN_MEDIA_NAME = ?", + arrayOf(id.toString()) + ) + } else { + throw IllegalArgumentException( + "Parameter `selection` should be empty when updating an ID" + ) + } + context?.contentResolver?.notifyChange(uri, null) + return rowsUpdated + } + + /** + * Handles the insertion of new bookmark pictures record to local SQLite Database + */ + override fun insert(uri: Uri, contentValues: ContentValues?): Uri { + val id = requireDb().insert(TABLE_NAME, null, contentValues) + context?.contentResolver?.notifyChange(uri, null) + return "$BASE_URI/$id".toUri() + } + + override fun delete(uri: Uri, s: String?, strings: Array?): Int { + val rows: Int = requireDb().delete( + TABLE_NAME, + "media_name = ?", + arrayOf(uri.lastPathSegment) + ) + context?.contentResolver?.notifyChange(uri, null) + return rows + } + + companion object { + private const val BASE_PATH = "bookmarks" + @JvmField + val BASE_URI: Uri = "content://${BuildConfig.BOOKMARK_AUTHORITY}/$BASE_PATH".toUri() + + @JvmStatic + fun uriForName(name: String): Uri = "$BASE_URI/$name".toUri() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt new file mode 100644 index 000000000..5ee88d973 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesController.kt @@ -0,0 +1,38 @@ +package fr.free.nrw.commons.bookmarks.pictures + +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.bookmarks.models.Bookmark +import fr.free.nrw.commons.media.MediaClient +import io.reactivex.Observable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BookmarkPicturesController @Inject constructor( + private val mediaClient: MediaClient, + private val bookmarkDao: BookmarkPicturesDao +) { + private var currentBookmarks: List = listOf() + + /** + * Loads the Media objects from the raw data stored in DB and the API. + * @return a list of bookmarked Media object + */ + fun loadBookmarkedPictures(): Single> { + val bookmarks = bookmarkDao.getAllBookmarks() + currentBookmarks = bookmarks + return Observable.fromIterable(bookmarks).flatMap { + mediaClient.getMedia(it.mediaName) + .toObservable() + .onErrorResumeNext(Observable.empty()) + }.toList() + } + + fun needRefreshBookmarkedPictures(): Boolean { + val bookmarks = bookmarkDao.getAllBookmarks() + return bookmarks.size != currentBookmarks.size + } + + fun stop() = Unit +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt new file mode 100644 index 000000000..00c8e3228 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.kt @@ -0,0 +1,144 @@ +package fr.free.nrw.commons.bookmarks.pictures + +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.os.RemoteException +import androidx.core.content.contentValuesOf +import fr.free.nrw.commons.bookmarks.models.Bookmark +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.Companion.BASE_URI +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.Companion.uriForName +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.ALL_FIELDS +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_CREATOR +import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable.COLUMN_MEDIA_NAME +import fr.free.nrw.commons.utils.getString +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class BookmarkPicturesDao @Inject constructor( + @param:Named("bookmarks") private val clientProvider: Provider +) { + /** + * Find all persisted pictures bookmarks on database + * + * @return list of bookmarks + */ + fun getAllBookmarks(): List { + val items: MutableList = mutableListOf() + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + BASE_URI, ALL_FIELDS, null, arrayOf(), null + ) + while (cursor != null && cursor.moveToNext()) { + items.add(fromCursor(cursor)) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return items + } + + /** + * Look for a bookmark in database and in order to insert or delete it + * + * @param bookmark : Bookmark object + * @return boolean : is bookmark now fav ? + */ + fun updateBookmark(bookmark: Bookmark): Boolean { + val bookmarkExists = findBookmark(bookmark) + if (bookmarkExists) { + deleteBookmark(bookmark) + } else { + addBookmark(bookmark) + } + return !bookmarkExists + } + + /** + * Add a Bookmark to database + * + * @param bookmark : Bookmark to add + */ + private fun addBookmark(bookmark: Bookmark) { + val db = clientProvider.get() + try { + db.insert(BASE_URI, toContentValues(bookmark)) + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Delete a bookmark from database + * + * @param bookmark : Bookmark to delete + */ + private fun deleteBookmark(bookmark: Bookmark) { + val db = clientProvider.get() + try { + if (bookmark.contentUri == null) { + throw RuntimeException("tried to delete item with no content URI") + } else { + db.delete(bookmark.contentUri!!, null, null) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Find a bookmark from database based on its name + * + * @param bookmark : Bookmark to find + * @return boolean : is bookmark in database ? + */ + fun findBookmark(bookmark: Bookmark?): Boolean { + if (bookmark == null) { + return false + } + + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + BASE_URI, ALL_FIELDS, "$COLUMN_MEDIA_NAME=?", arrayOf(bookmark.mediaName), null + ) + if (cursor != null && cursor.moveToFirst()) { + return true + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return false + } + + fun fromCursor(cursor: Cursor): Bookmark { + var fileName = cursor.getString(COLUMN_MEDIA_NAME) + if (fileName == null) { + fileName = "" + } + return Bookmark( + fileName, cursor.getString(COLUMN_CREATOR), uriForName(fileName) + ) + } + + private fun toContentValues(bookmark: Bookmark): ContentValues = contentValuesOf( + COLUMN_MEDIA_NAME to bookmark.mediaName, + COLUMN_CREATOR to bookmark.mediaCreator + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt new file mode 100644 index 000000000..e8c61371a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.kt @@ -0,0 +1,201 @@ +package fr.free.nrw.commons.bookmarks.pictures + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView.OnItemClickListener +import android.widget.ListAdapter +import dagger.android.support.DaggerFragment +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment +import fr.free.nrw.commons.category.GridViewAdapter +import fr.free.nrw.commons.databinding.FragmentBookmarksPicturesBinding +import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished +import fr.free.nrw.commons.utils.ViewUtil.showShortSnackbar +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import javax.inject.Inject + +class BookmarkPicturesFragment : DaggerFragment() { + private var gridAdapter: GridViewAdapter? = null + private val compositeDisposable = CompositeDisposable() + + private var binding: FragmentBookmarksPicturesBinding? = null + + @JvmField + @Inject + var controller: BookmarkPicturesController? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentBookmarksPicturesBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding!!.bookmarkedPicturesList.onItemClickListener = + parentFragment as OnItemClickListener? + initList() + } + + override fun onStop() { + super.onStop() + controller!!.stop() + } + + override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + binding = null + } + + override fun onResume() { + super.onResume() + if (controller!!.needRefreshBookmarkedPictures()) { + binding!!.bookmarkedPicturesList.visibility = View.GONE + gridAdapter?.let { + it.clear() + (parentFragment as BookmarkListRootFragment).viewPagerNotifyDataSetChanged() + } + initList() + } + } + + /** + * Checks for internet connection and then initializes + * the recycler view with bookmarked pictures + */ + @SuppressLint("CheckResult") + private fun initList() { + if (!isInternetConnectionEstablished(context)) { + handleNoInternet() + return + } + + binding!!.loadingImagesProgressBar.visibility = View.VISIBLE + binding!!.statusMessage.visibility = View.GONE + + compositeDisposable.add( + controller!!.loadBookmarkedPictures() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(::handleSuccess, ::handleError) + ) + } + + /** + * Handles the UI updates for no internet scenario + */ + private fun handleNoInternet() { + binding!!.loadingImagesProgressBar.visibility = View.GONE + if (gridAdapter == null || gridAdapter!!.isEmpty) { + binding!!.statusMessage.visibility = View.VISIBLE + binding!!.statusMessage.text = getString(R.string.no_internet) + } else { + showShortSnackbar(binding!!.parentLayout, R.string.no_internet) + } + } + + /** + * Logs and handles API error scenario + * @param throwable + */ + private fun handleError(throwable: Throwable) { + Timber.e(throwable, "Error occurred while loading images inside a category") + try { + showShortSnackbar(binding!!.root, R.string.error_loading_images) + initErrorView() + } catch (e: Exception) { + Timber.e(e) + } + } + + /** + * Handles the UI updates for a error scenario + */ + private fun initErrorView() { + binding!!.loadingImagesProgressBar.visibility = View.GONE + if (gridAdapter == null || gridAdapter!!.isEmpty) { + binding!!.statusMessage.visibility = View.VISIBLE + binding!!.statusMessage.text = getString(R.string.no_images_found) + } else { + binding!!.statusMessage.visibility = View.GONE + } + } + + /** + * Handles the UI updates when there is no bookmarks + */ + private fun initEmptyBookmarkListView() { + binding!!.loadingImagesProgressBar.visibility = View.GONE + if (gridAdapter == null || gridAdapter!!.isEmpty) { + binding!!.statusMessage.visibility = View.VISIBLE + binding!!.statusMessage.text = getString(R.string.bookmark_empty) + } else { + binding!!.statusMessage.visibility = View.GONE + } + } + + /** + * Handles the success scenario + * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter + * @param collection List of new Media to be displayed + */ + private fun handleSuccess(collection: List?) { + if (collection == null) { + initErrorView() + return + } + if (collection.isEmpty()) { + initEmptyBookmarkListView() + return + } + + if (gridAdapter == null) { + setAdapter(collection) + } else { + if (gridAdapter!!.containsAll(collection)) { + binding!!.loadingImagesProgressBar.visibility = View.GONE + binding!!.statusMessage.visibility = View.GONE + binding!!.bookmarkedPicturesList.visibility = View.VISIBLE + binding!!.bookmarkedPicturesList.adapter = gridAdapter + return + } + gridAdapter!!.addItems(collection) + (parentFragment as BookmarkListRootFragment).viewPagerNotifyDataSetChanged() + } + binding!!.loadingImagesProgressBar.visibility = View.GONE + binding!!.statusMessage.visibility = View.GONE + binding!!.bookmarkedPicturesList.visibility = View.VISIBLE + } + + /** + * Initializes the adapter with a list of Media objects + * @param mediaList List of new Media to be displayed + */ + private fun setAdapter(mediaList: List) { + gridAdapter = GridViewAdapter( + requireContext(), + R.layout.layout_category_images, + mediaList.toMutableList() + ) + binding?.let { it.bookmarkedPicturesList.adapter = gridAdapter } + } + + /** + * It return an instance of gridView adapter which helps in extracting media details + * used by the gridView + * @return GridView Adapter + */ + fun getAdapter(): ListAdapter? = binding?.bookmarkedPicturesList?.adapter +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt new file mode 100644 index 000000000..6a8f4d541 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarksTable.kt @@ -0,0 +1,54 @@ +package fr.free.nrw.commons.bookmarks.pictures + +import android.database.sqlite.SQLiteDatabase + +object BookmarksTable { + const val TABLE_NAME: String = "bookmarks" + const val COLUMN_MEDIA_NAME: String = "media_name" + const val COLUMN_CREATOR: String = "media_creator" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + val ALL_FIELDS = arrayOf( + COLUMN_MEDIA_NAME, + COLUMN_CREATOR + ) + + const val DROP_TABLE_STATEMENT: String = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT: String = ("CREATE TABLE $TABLE_NAME (" + + "$COLUMN_MEDIA_NAME STRING PRIMARY KEY, " + + "$COLUMN_CREATOR STRING" + + ");") + + fun onCreate(db: SQLiteDatabase) = + db.execSQL(CREATE_TABLE_STATEMENT) + + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) { + return + } + + if (from < 7) { + // doesn't exist yet + onUpdate(db, from+1, to) + return + } + + if (from == 7) { + // table added in version 8 + onCreate(db) + onUpdate(db, from+1, to) + return + } + + if (from == 8) { + onUpdate(db, from+1, to) + return + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt new file mode 100644 index 000000000..9f94e8592 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt @@ -0,0 +1,14 @@ +package fr.free.nrw.commons.campaigns + +import com.google.gson.annotations.SerializedName + +/** + * A data class to hold the campaign configs + */ +class CampaignConfig { + @SerializedName("showOnlyLiveCampaigns") + var showOnlyLiveCampaigns = false + + @SerializedName("sortBy") + var sortBy: String? = null +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt new file mode 100644 index 000000000..1656109e7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.campaigns + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.campaigns.models.Campaign + +/** + * Data class to hold the response from the campaigns api + */ +class CampaignResponseDTO { + @SerializedName("config") + var campaignConfig: CampaignConfig? = null + + @SerializedName("campaigns") + var campaigns: List? = null +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt new file mode 100644 index 000000000..7a30ff5c4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.campaigns + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.core.content.ContextCompat +import fr.free.nrw.commons.R +import fr.free.nrw.commons.campaigns.models.Campaign +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.LayoutCampaginBinding +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort +import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString +import fr.free.nrw.commons.utils.SwipableCardView +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import fr.free.nrw.commons.utils.handleWebUrl +import timber.log.Timber +import java.text.ParseException + +/** + * A view which represents a single campaign + */ +class CampaignView : SwipableCardView { + private var campaign: Campaign? = null + private var binding: LayoutCampaginBinding? = null + private var viewHolder: ViewHolder? = null + private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE + + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, attrs, defStyleAttr) { + init() + } + + fun setCampaign(campaign: Campaign?) { + this.campaign = campaign + if (campaign != null) { + if (campaign.isWLMCampaign) { + campaignPreference = WLM_CARD_PREFERENCE + } + visibility = VISIBLE + viewHolder!!.init() + } else { + visibility = GONE + } + } + + override fun onSwipe(view: View): Boolean { + view.visibility = GONE + (context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false) + showLongToast( + context, + resources.getString(R.string.nearby_campaign_dismiss_message) + ) + return true + } + + private fun init() { + binding = LayoutCampaginBinding.inflate( + LayoutInflater.from(context), this, true + ) + viewHolder = ViewHolder() + setOnClickListener { + campaign?.let { + if (it.isWLMCampaign) { + ((context) as MainActivity).showNearby() + } else { + handleWebUrl(context, Uri.parse(it.link)) + } + } + } + } + + inner class ViewHolder { + fun init() { + if (campaign != null) { + binding!!.ivCampaign.setImageDrawable( + ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign) + ) + binding!!.tvTitle.text = campaign!!.title + binding!!.tvDescription.text = campaign!!.description + try { + if (campaign!!.isWLMCampaign) { + binding!!.tvDates.text = String.format( + "%1s - %2s", campaign!!.startDate, + campaign!!.endDate + ) + } else { + val startDate = getIso8601DateFormatShort().parse( + campaign?.startDate + ) + val endDate = getIso8601DateFormatShort().parse( + campaign?.endDate + ) + binding!!.tvDates.text = String.format( + "%1s - %2s", getExtraShortDateString( + startDate!! + ), getExtraShortDateString(endDate!!) + ) + } + } catch (e: ParseException) { + Timber.e(e) + } + } + } + } + + companion object { + const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView" + const val WLM_CARD_PREFERENCE: String = "displayWLMCardView" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt new file mode 100644 index 000000000..53013c1ae --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -0,0 +1,106 @@ +package fr.free.nrw.commons.campaigns + +import android.annotation.SuppressLint +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.campaigns.models.Campaign +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort +import io.reactivex.Scheduler +import io.reactivex.disposables.Disposable +import timber.log.Timber +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on + * success and error + */ +@Singleton +class CampaignsPresenter @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient?, + @param:Named(IO_THREAD) private val ioScheduler: Scheduler, + @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler +) : BasePresenter { + private var view: ICampaignsView? = null + private var disposable: Disposable? = null + private var campaign: Campaign? = null + + override fun onAttachView(view: ICampaignsView) { + this.view = view + } + + override fun onDetachView() { + view = null + disposable?.dispose() + } + + /** + * make the api call to fetch the campaigns + */ + @SuppressLint("CheckResult") + fun getCampaigns() { + if (view != null && okHttpJsonApiClient != null) { + //If we already have a campaign, lets not make another call + if (campaign != null) { + view!!.showCampaigns(campaign) + return + } + + okHttpJsonApiClient.getCampaigns() + .observeOn(mainThreadScheduler) + .subscribeOn(ioScheduler) + .doOnSubscribe { disposable = it } + .subscribe({ campaignResponseDTO -> + val campaigns = campaignResponseDTO?.campaigns?.toMutableList() + if (campaigns.isNullOrEmpty()) { + Timber.e("The campaigns list is empty") + view!!.showCampaigns(null) + } else { + sortCampaignsByStartDate(campaigns) + campaign = findActiveCampaign(campaigns) + view!!.showCampaigns(campaign) + } + }, { + Timber.e(it, "could not fetch campaigns") + }) + } + } + + private fun sortCampaignsByStartDate(campaigns: MutableList) { + val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() + campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign -> + val date1: Date? + val date2: Date? + try { + date1 = campaign.startDate?.let { dateFormat.parse(it) } + date2 = other.startDate?.let { dateFormat.parse(it) } + } catch (e: ParseException) { + Timber.e(e) + return@Comparator -1 + } + if (date1 != null && date2 != null) date1.compareTo(date2) else -1 + }) + } + + private fun findActiveCampaign(campaigns: List) : Campaign? { + val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() + val currentDate = Date() + return try { + campaigns.firstOrNull { + val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) } + val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) } + campaignStartDate != null && campaignEndDate != null && + campaignEndDate >= currentDate && campaignStartDate <= currentDate + } + } catch (e: ParseException) { + Timber.e(e, "could not find active campaign") + null + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt new file mode 100644 index 000000000..1cbf7da1f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt @@ -0,0 +1,10 @@ +package fr.free.nrw.commons.campaigns + +import fr.free.nrw.commons.campaigns.models.Campaign + +/** + * Interface which defines the view contracts of the campaign view + */ +interface ICampaignsView { + fun showCampaigns(campaign: Campaign?) +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt new file mode 100644 index 000000000..cd68797e0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.campaigns.models + +/** + * A data class to hold a campaign + */ +data class Campaign( + var title: String? = null, + var description: String? = null, + var startDate: String? = null, + var endDate: String? = null, + var link: String? = null, + var isWLMCampaign: Boolean = false, +) diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt new file mode 100644 index 000000000..47147944c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -0,0 +1,332 @@ +package fr.free.nrw.commons.category + +import android.text.TextUtils +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.upload.GpsCategoryModel +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import fr.free.nrw.commons.utils.StringSortingUtils +import io.reactivex.Observable +import io.reactivex.functions.Function4 +import timber.log.Timber +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +/** + * The model class for categories in upload + */ +class CategoriesModel + @Inject + constructor( + private val categoryClient: CategoryClient, + private val categoryDao: CategoryDao, + private val gpsCategoryModel: GpsCategoryModel, + ) { + private val selectedCategories: MutableList = mutableListOf() + + /** + * Existing categories which are selected + */ + private var selectedExistingCategories: MutableList = mutableListOf() + + /** + * Returns true if an item is considered to be a spammy category which should be ignored + * + * @param item a category item that needs to be validated to know if it is spammy or not + * @return + */ + fun isSpammyCategory(item: String): Boolean { + + // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) + val spammyCategory = item.matches("(.*)needing(.*)".toRegex()) + || item.matches("(.*)taken on(.*)".toRegex()) + + // checks for + // dd/mm/yyyy or yy + // yyyy or yy/mm/dd + // yyyy or yy/mm + // mm/yyyy or yy + // for `yy` it is assumed that 20XX is implicit. + // with separators [., /, -] + val isIrrelevantCategory = + item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex()) + + + if (spammyCategory) { + return true + } + + if(isIrrelevantCategory){ + return true + } + + val hasYear = item.matches("(.*\\d{4}.*)".toRegex()) + val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex()) + + // finally if there's 4 digits year exists in XXXX it should only be in 20XX range. + return hasYear && !validYearsRange + } + + /** + * Updates category count in category dao + * @param item + */ + fun updateCategoryCount(item: CategoryItem) { + var category = categoryDao.find(item.name) + + // Newly used category... + if (category == null) { + category = Category( + null, item.name, + item.description, + item.thumbnail, + Date(), + 0 + ) + } + category.incTimesUsed() + categoryDao.save(category) + } + + /** + * Regional category search + * @param term + * @param imageTitleList + * @return + */ + fun searchAll( + term: String, + imageTitleList: List, + selectedDepictions: List, + ): Observable> = + suggestionsOrSearch(term, imageTitleList, selectedDepictions) + .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } + + private fun suggestionsOrSearch( + term: String, + imageTitleList: List, + selectedDepictions: List, + ): Observable> = + if (TextUtils.isEmpty(term)) { + Observable.combineLatest( + categoriesFromDepiction(selectedDepictions), + gpsCategoryModel.categoriesFromLocation, + titleCategories(imageTitleList), + Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), + Function4(::combine), + ) + } else { + categoryClient + .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) + .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } + .toObservable() + } + + /** + * Fetches details of every category associated with selected depictions, converts them into + * CategoryItem and returns them in a list. + * If a selected depiction has no categories, the categories in which its P18 belongs are + * returned in the list. + * + * @param selectedDepictions selected DepictItems + * @return List of CategoryItem associated with selected depictions + */ + private fun categoriesFromDepiction(selectedDepictions: List): Observable>? { + val observables = selectedDepictions.map { depictedItem -> + if (depictedItem.commonsCategories.isEmpty()) { + if (depictedItem.primaryImage == null) { + return@map Observable.just(emptyList()) + } + Observable.just( + depictedItem.primaryImage + ).map { image -> + categoryClient + .getCategoriesOfImage( + image, + SEARCH_CATS_LIMIT, + ).map { + it.map { category -> + CategoryItem( + category.name, + category.description, + category.thumbnail, + category.isSelected, + ) + } + }.blockingGet() + }.flatMapIterable { it }.toList() + .toObservable() + } else { + Observable + .fromIterable( + depictedItem.commonsCategories, + ).map { categoryItem -> + categoryClient + .getCategoriesByName( + categoryItem.name, + categoryItem.name, + SEARCH_CATS_LIMIT, + ).map { + CategoryItem( + it[0].name, + it[0].description, + it[0].thumbnail, + it[0].isSelected, + ) + }.blockingGet() + }.toList() + .toObservable() + } + } + return Observable.concat(observables) + .scan(mutableListOf()) { accumulator, currentList -> + accumulator.apply { addAll(currentList) } + } + } + + /** + * Fetches details of every category by their name, converts them into + * CategoryItem and returns them in a list. + * + * @param categoryNames selected Categories + * @return List of CategoryItem + */ + fun getCategoriesByName(categoryNames: List): Observable>? = + Observable + .fromIterable(categoryNames) + .map { categoryName -> + buildCategories(categoryName) + }.filter { categoryItem -> + categoryItem.name != "Hidden" + }.toList() + .toObservable() + + /** + * Fetches the categories and converts them into CategoryItem + */ + fun buildCategories(categoryName: String): CategoryItem = + categoryClient + .getCategoriesByName( + categoryName, + categoryName, + SEARCH_CATS_LIMIT, + ).map { + if (it.isNotEmpty()) { + CategoryItem( + it[0].name, + it[0].description, + it[0].thumbnail, + it[0].isSelected, + ) + } else { + CategoryItem( + "Hidden", + "Hidden", + "hidden", + false, + ) + } + }.blockingGet() + + private fun combine( + depictionCategories: List, + locationCategories: List, + titles: List, + recents: List, + ) = depictionCategories + locationCategories + titles + recents + + /** + * Returns title based categories + * @param titleList + * @return + */ + private fun titleCategories(titleList: List) = + if (titleList.isNotEmpty()) { + Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> + searchResults.map { it as List }.flatten() + } + } else { + Observable.just(emptyList()) + } + + /** + * Return category for single title + * @param title + * @return + */ + private fun getTitleCategories(title: String): Observable> = + categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() + + /** + * Handles category item selection + * @param item + */ + fun onCategoryItemClicked( + item: CategoryItem, + media: Media?, + ) { + if (media == null) { + if (item.isSelected) { + selectedCategories.add(item) + updateCategoryCount(item) + } else { + selectedCategories.remove(item) + } + } else { + if (item.isSelected) { + if (media.categories?.contains(item.name) == true) { + selectedExistingCategories.add(item.name) + } else { + selectedCategories.add(item) + updateCategoryCount(item) + } + } else { + if (media.categories?.contains(item.name) == true) { + selectedExistingCategories.remove(item.name) + if (!media.categories?.contains(item.name)!!) { + val categoriesList: MutableList = ArrayList() + categoriesList.add(item.name) + categoriesList.addAll(media.categories!!) + media.categories = categoriesList + } + } else { + selectedCategories.remove(item) + } + } + } + } + + /** + * Get Selected Categories + * @return + */ + fun getSelectedCategories(): List = selectedCategories + + /** + * Cleanup the existing in memory cache's + */ + fun cleanUp() { + selectedCategories.clear() + selectedExistingCategories.clear() + } + + companion object { + const val SEARCH_CATS_LIMIT = 25 + } + + /** + * Provides selected existing categories + * + * @return selected existing categories + */ + fun getSelectedExistingCategories(): List = selectedExistingCategories + + /** + * Initialize existing categories + * + * @param selectedExistingCategories existing categories + */ + fun setSelectedExistingCategories(selectedExistingCategories: MutableList) { + this.selectedExistingCategories = selectedExistingCategories + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt new file mode 100644 index 000000000..e4bfb957a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.category + +import android.net.Uri +import java.util.Date + +data class Category( + var contentUri: Uri? = null, + val name: String? = null, + val description: String? = null, + val thumbnail: String? = null, + val lastUsed: Date? = null, + var timesUsed: Int = 0 +) { + fun incTimesUsed() { + timesUsed++ + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt new file mode 100644 index 000000000..ef4ec3d39 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface CategoryClickedListener { + fun categoryClicked(item: CategoryItem) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt new file mode 100644 index 000000000..b031f12f1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt @@ -0,0 +1,157 @@ +package fr.free.nrw.commons.category + +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + +const val CATEGORY_PREFIX = "Category:" +const val SUB_CATEGORY_CONTINUATION_PREFIX = "sub_category_" +const val PARENT_CATEGORY_CONTINUATION_PREFIX = "parent_category_" +const val CATEGORY_UNCATEGORISED = "uncategorised" +const val CATEGORY_NEEDING_CATEGORIES = "needing categories" + +/** + * Category Client to handle custom calls to Commons MediaWiki APIs + */ +@Singleton +class CategoryClient + @Inject + constructor( + private val categoryInterface: CategoryInterface, + ) : ContinuationClient() { + /** + * Searches for categories containing the specified string. + * + * @param filter The string to be searched + * @param itemLimit How many results are returned + * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result + * @return + */ + @JvmOverloads + fun searchCategories( + filter: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) + + /** + * Searches for categories starting with the specified string. + * + * @param prefix The prefix to be searched + * @param itemLimit How many results are returned + * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result + * @return + */ + @JvmOverloads + fun searchCategoriesForPrefix( + prefix: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = + responseMapper( + categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset), + ) + + /** + * Fetches categories starting and ending with a specified name. + * + * @param startingCategoryName Name of the category to start + * @param endingCategoryName Name of the category to end + * @param itemLimit How many categories to return + * @param offset offset + * @return MwQueryResponse + */ + @JvmOverloads + fun getCategoriesByName( + startingCategoryName: String?, + endingCategoryName: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = + responseMapper( + categoryInterface.getCategoriesByName( + startingCategoryName, + endingCategoryName, + itemLimit, + offset, + ), + ) + + /** + * Fetches categories belonging to an image (P18 of some wikidata entity). + * + * @param image P18 of some wikidata entity + * @param itemLimit How many categories to return + * @return Single Observable emitting the list of categories + */ + fun getCategoriesOfImage( + image: String, + itemLimit: Int, + ): Single> = + responseMapper( + categoryInterface.getCategoriesByTitles( + "File:${image}", + itemLimit, + ), + ) + + /** + * The method takes categoryName as input and returns a List of Subcategories + * It uses the generator query API to get the subcategories in a category, 500 at a time. + * + * @param categoryName Category name as defined on commons + * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. + */ + fun getSubCategoryList(categoryName: String): Single> = + continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { + categoryInterface.getSubCategoryList( + categoryName, + it, + ) + } + + /** + * The method takes categoryName as input and returns a List of parent categories + * It uses the generator query API to get the parent categories of a category, 500 at a time. + * + * @param categoryName Category name as defined on commons + * @return + */ + fun getParentCategoryList(categoryName: String): Single> = + continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { + categoryInterface.getParentCategoryList(categoryName, it) + } + + fun resetSubCategoryContinuation(category: String) { + resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) + } + + fun resetParentCategoryContinuation(category: String) { + resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) + } + + override fun responseMapper( + networkResult: Single, + key: String?, + ): Single> = + networkResult + .map { + handleContinuationResponse(it.continuation(), key) + it.query()?.pages() ?: emptyList() + }.map { + it + .filter { page -> + // Null check is not redundant because some values could be null + // for mocks when running unit tests + page.categoryInfo()?.isHidden != true + }.map { + CategoryItem( + it.title().replace(CATEGORY_PREFIX, ""), + it.description().toString(), + it.thumbUrl().toString(), + false, + ) + } + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt new file mode 100644 index 000000000..f5cec0fce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt @@ -0,0 +1,188 @@ +package fr.free.nrw.commons.category + + +import android.content.ContentValues +import android.content.UriMatcher +import android.content.UriMatcher.NO_MATCH +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteQueryBuilder +import android.net.Uri +import android.text.TextUtils +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.di.CommonsDaggerContentProvider +import androidx.core.net.toUri + +class CategoryContentProvider : CommonsDaggerContentProvider() { + + private val uriMatcher = UriMatcher(NO_MATCH).apply { + addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES) + addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID) + } + + @SuppressWarnings("ConstantConditions") + override fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? { + val queryBuilder = SQLiteQueryBuilder().apply { + tables = TABLE_NAME + } + + val uriType = uriMatcher.match(uri) + val db = requireDb() + + val cursor: Cursor? = when (uriType) { + CATEGORIES -> queryBuilder.query( + db, + projection, + selection, + selectionArgs, + null, + null, + sortOrder + ) + CATEGORIES_ID -> queryBuilder.query( + db, + ALL_FIELDS, + "_id = ?", + arrayOf(uri.lastPathSegment), + null, + null, + sortOrder + ) + else -> throw IllegalArgumentException("Unknown URI $uri") + } + + cursor?.setNotificationUri(requireContext().contentResolver, uri) + return cursor + } + + override fun getType(uri: Uri): String? = null + + @SuppressWarnings("ConstantConditions") + override fun insert(uri: Uri, contentValues: ContentValues?): Uri { + val uriType = uriMatcher.match(uri) + val id: Long + when (uriType) { + CATEGORIES -> { + id = requireDb().insert(TABLE_NAME, null, contentValues) + } + else -> throw IllegalArgumentException("Unknown URI: $uri") + } + requireContext().contentResolver?.notifyChange(uri, null) + return "${BASE_URI}/$id".toUri() + } + + @SuppressWarnings("ConstantConditions") + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + @SuppressWarnings("ConstantConditions") + override fun bulkInsert(uri: Uri, values: Array): Int { + val uriType = uriMatcher.match(uri) + val sqlDB = requireDb() + sqlDB.beginTransaction() + when (uriType) { + CATEGORIES -> { + for (value in values) { + sqlDB.insert(TABLE_NAME, null, value) + } + sqlDB.setTransactionSuccessful() + } + else -> throw IllegalArgumentException("Unknown URI: $uri") + } + sqlDB.endTransaction() + requireContext().contentResolver?.notifyChange(uri, null) + return values.size + } + + @SuppressWarnings("ConstantConditions") + override fun update(uri: Uri, contentValues: ContentValues?, selection: String?, + selectionArgs: Array?): Int { + val uriType = uriMatcher.match(uri) + val rowsUpdated: Int + when (uriType) { + CATEGORIES_ID -> { + if (TextUtils.isEmpty(selection)) { + val id = uri.lastPathSegment?.toInt() + ?: throw IllegalArgumentException("Invalid ID") + rowsUpdated = requireDb().update( + TABLE_NAME, + contentValues, + "$COLUMN_ID = ?", + arrayOf(id.toString()) + ) + } else { + throw IllegalArgumentException( + "Parameter `selection` should be empty when updating an ID") + } + } + else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType") + } + requireContext().contentResolver?.notifyChange(uri, null) + return rowsUpdated + } + + companion object { + const val TABLE_NAME = "categories" + + const val COLUMN_ID = "_id" + const val COLUMN_NAME = "name" + const val COLUMN_DESCRIPTION = "description" + const val COLUMN_THUMBNAIL = "thumbnail" + const val COLUMN_LAST_USED = "last_used" + const val COLUMN_TIMES_USED = "times_used" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + val ALL_FIELDS = arrayOf( + COLUMN_ID, + COLUMN_NAME, + COLUMN_DESCRIPTION, + COLUMN_THUMBNAIL, + COLUMN_LAST_USED, + COLUMN_TIMES_USED + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + + "$COLUMN_ID INTEGER PRIMARY KEY," + + "$COLUMN_NAME TEXT," + + "$COLUMN_DESCRIPTION TEXT," + + "$COLUMN_THUMBNAIL TEXT," + + "$COLUMN_LAST_USED INTEGER," + + "$COLUMN_TIMES_USED INTEGER" + + ");" + + fun uriForId(id: Int): Uri = Uri.parse("${BASE_URI}/$id") + + fun onCreate(db: SQLiteDatabase) = db.execSQL(CREATE_TABLE_STATEMENT) + + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) return + if (from < 4) { + // doesn't exist yet + onUpdate(db, from + 1, to) + } else if (from == 4) { + // table added in version 5 + onCreate(db) + onUpdate(db, from + 1, to) + } else if (from == 5) { + onUpdate(db, from + 1, to) + } else if (from == 17) { + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;") + onUpdate(db, from + 1, to) + } + } + + // For URI matcher + private const val CATEGORIES = 1 + private const val CATEGORIES_ID = 2 + private const val BASE_PATH = "categories" + val BASE_URI: Uri = "content://${BuildConfig.CATEGORY_AUTHORITY}/${BASE_PATH}".toUri() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt new file mode 100644 index 000000000..3371da184 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt @@ -0,0 +1,194 @@ +package fr.free.nrw.commons.category + +import android.annotation.SuppressLint +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.os.RemoteException + +import java.util.ArrayList +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider + +class CategoryDao @Inject constructor( + @Named("category") private val clientProvider: Provider +) { + + fun save(category: Category) { + val db = clientProvider.get() + try { + if (category.contentUri == null) { + category.contentUri = db.insert( + CategoryContentProvider.BASE_URI, + toContentValues(category) + ) + } else { + db.update( + category.contentUri!!, + toContentValues(category), + null, + null + ) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Find persisted category in database, based on its name. + * + * @param name Category's name + * @return category from database, or null if not found + */ + fun find(name: String): Category? { + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + CategoryContentProvider.BASE_URI, + ALL_FIELDS, + "${COLUMN_NAME}=?", + arrayOf(name), + null + ) + if (cursor != null && cursor.moveToFirst()) { + return fromCursor(cursor) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return null + } + + /** + * Retrieve recently-used categories, ordered by descending date. + * + * @return a list containing recent categories + */ + fun recentCategories(limit: Int): List { + val items = ArrayList() + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + CategoryContentProvider.BASE_URI, + ALL_FIELDS, + null, + emptyArray(), + "$COLUMN_LAST_USED DESC" + ) + while (cursor != null && cursor.moveToNext() && cursor.position < limit) { + val category = fromCursor(cursor) + if (category.name != null) { + items.add( + CategoryItem( + category.name, + category.description, + category.thumbnail, + false + ) + ) + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return items + } + + @SuppressLint("Range") + fun fromCursor(cursor: Cursor): Category { + // Hardcoding column positions! + return Category( + CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))), + cursor.getString(cursor.getColumnIndex(COLUMN_NAME)), + cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)), + cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)), + Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))), + cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED)) + ) + } + + private fun toContentValues(category: Category): ContentValues { + return ContentValues().apply { + put(COLUMN_NAME, category.name) + put(COLUMN_DESCRIPTION, category.description) + put(COLUMN_THUMBNAIL, category.thumbnail) + put(COLUMN_LAST_USED, category.lastUsed?.time) + put(COLUMN_TIMES_USED, category.timesUsed) + } + } + + companion object Table { + const val TABLE_NAME = "categories" + + const val COLUMN_ID = "_id" + const val COLUMN_NAME = "name" + const val COLUMN_DESCRIPTION = "description" + const val COLUMN_THUMBNAIL = "thumbnail" + const val COLUMN_LAST_USED = "last_used" + const val COLUMN_TIMES_USED = "times_used" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + val ALL_FIELDS = arrayOf( + COLUMN_ID, + COLUMN_NAME, + COLUMN_DESCRIPTION, + COLUMN_THUMBNAIL, + COLUMN_LAST_USED, + COLUMN_TIMES_USED + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + + "$COLUMN_ID INTEGER PRIMARY KEY," + + "$COLUMN_NAME STRING," + + "$COLUMN_DESCRIPTION STRING," + + "$COLUMN_THUMBNAIL STRING," + + "$COLUMN_LAST_USED INTEGER," + + "$COLUMN_TIMES_USED INTEGER" + + ");" + + @SuppressLint("SQLiteString") + fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_TABLE_STATEMENT) + } + + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + @SuppressLint("SQLiteString") + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) return + if (from < 4) { + // doesn't exist yet + onUpdate(db, from + 1, to) + } else if (from == 4) { + // table added in version 5 + onCreate(db) + onUpdate(db, from + 1, to) + } else if (from == 5) { + onUpdate(db, from + 1, to) + } else if (from == 17) { + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;") + onUpdate(db, from + 1, to) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt new file mode 100644 index 000000000..fefe462a9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -0,0 +1,265 @@ +package fr.free.nrw.commons.category + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import fr.free.nrw.commons.BuildConfig.COMMONS_URL +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.ViewPagerAdapter +import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding +import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment +import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment +import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailProvider +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets +import fr.free.nrw.commons.utils.handleWebUrl +import fr.free.nrw.commons.wikidata.model.WikiSite +import fr.free.nrw.commons.wikidata.model.page.PageTitle +import kotlinx.coroutines.launch +import javax.inject.Inject + + +/** + * This activity displays details of a particular category + * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in + * a particular category on wikimedia commons. + */ +class CategoryDetailsActivity : BaseActivity(), + MediaDetailProvider, + CategoryImagesCallback { + + private lateinit var supportFragmentManager: FragmentManager + private lateinit var categoriesMediaFragment: CategoriesMediaFragment + private var mediaDetails: MediaDetailPagerFragment? = null + private var categoryName: String? = null + private lateinit var viewPagerAdapter: ViewPagerAdapter + + private lateinit var binding: ActivityCategoryDetailsBinding + + @Inject + lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory + + private val viewModel: CategoryDetailsViewModel by viewModels { categoryViewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityCategoryDetailsBinding.inflate(layoutInflater) + val view = binding.root + applyEdgeToEdgeAllInsets(view) + setContentView(view) + supportFragmentManager = getSupportFragmentManager() + viewPagerAdapter = ViewPagerAdapter(this, supportFragmentManager) + binding.viewPager.adapter = viewPagerAdapter + binding.viewPager.offscreenPageLimit = 2 + binding.tabLayout.setupWithViewPager(binding.viewPager) + setSupportActionBar(binding.toolbarBinding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + setTabs() + setPageTitle() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED){ + viewModel.bookmarkState.collect { + invalidateOptionsMenu() + } + } + } + + } + + /** + * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, + * Set the fragments according to the tab selected in the viewPager. + */ + private fun setTabs() { + categoriesMediaFragment = CategoriesMediaFragment() + val subCategoryListFragment = SubCategoriesFragment() + val parentCategoriesFragment = ParentCategoriesFragment() + categoryName = intent?.getStringExtra("categoryName") + if (intent != null && categoryName != null) { + val arguments = Bundle().apply { + putString("categoryName", categoryName) + } + categoriesMediaFragment.arguments = arguments + subCategoryListFragment.arguments = arguments + parentCategoriesFragment.arguments = arguments + + viewModel.onCheckIfBookmarked(categoryName!!) + } + + viewPagerAdapter.setTabs( + R.string.title_for_media to categoriesMediaFragment, + R.string.title_for_subcategories to subCategoryListFragment, + R.string.title_for_parent_categories to parentCategoriesFragment + ) + viewPagerAdapter.notifyDataSetChanged() + } + + /** + * Gets the passed categoryName from the intents and displays it as the page title + */ + private fun setPageTitle() { + intent?.getStringExtra("categoryName")?.let { + title = it + } + } + + /** + * This method is called onClick of media inside category details (CategoryImageListFragment). + */ + override fun onMediaClicked(position: Int) { + binding.tabLayout.visibility = View.GONE + binding.viewPager.visibility = View.GONE + binding.mediaContainer.visibility = View.VISIBLE + if (mediaDetails == null || mediaDetails?.isVisible == false) { + // set isFeaturedImage true for featured images, to include author field on media detail + mediaDetails = MediaDetailPagerFragment.newInstance(false, true) + supportFragmentManager.beginTransaction() + .replace(R.id.mediaContainer, mediaDetails!!) + .addToBackStack(null) + .commit() + supportFragmentManager.executePendingTransactions() + } + mediaDetails?.showImage(position) + } + + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * @param context A Context of the application package implementing this class. + * @param categoryName Name of the category for displaying its details + */ + fun startYourself(context: Context?, categoryName: String) { + val intent = Intent(context, CategoryDetailsActivity::class.java).apply { + putExtra("categoryName", categoryName) + } + context?.startActivity(intent) + } + } + + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * @param i It is the index of which media object is to be returned which is same as + * current index of viewPager. + * @return Media Object + */ + override fun getMediaAtPosition(i: Int): Media? { + return categoriesMediaFragment.getMediaAtPosition(i) + } + + /** + * This method is called on from getCount of MediaDetailPagerFragment + * The viewpager will contain same number of media items as that of media elements in adapter. + * @return Total Media count in the adapter + */ + override fun getTotalMediaCount(): Int { + return categoriesMediaFragment.getTotalMediaCount() + } + + override fun getContributionStateAt(position: Int): Int? { + return null + } + + /** + * Reload media detail fragment once media is nominated + * + * @param index item position that has been nominated + */ + override fun refreshNominatedMedia(index: Int) { + if (supportFragmentManager.backStackEntryCount == 1) { + onBackPressed() + onMediaClicked(index) + } + } + + /** + * This method inflates the menu in the toolbar + */ + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.fragment_category_detail, menu) + return super.onCreateOptionsMenu(menu) + } + + /** + * This method handles the logic on ItemSelect in toolbar menu + * Currently only 1 choice is available to open category details page in browser + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_browser_current_category -> { + val title = PageTitle(CATEGORY_PREFIX + categoryName, WikiSite(COMMONS_URL)) + + handleWebUrl(this, Uri.parse(title.canonicalUri)) + true + } + + R.id.menu_bookmark_current_category -> { + categoryName?.let { + viewModel.onBookmarkClick(categoryName = it) + } + true + } + + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + menu?.run { + val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category) + if (bookmarkMenuItem != null) { + val icon = if(viewModel.bookmarkState.value){ + R.drawable.menu_ic_round_star_filled_24px + } else { + R.drawable.menu_ic_round_star_border_24px + } + + bookmarkMenuItem.setIcon(icon) + } + } + return super.onPrepareOptionsMenu(menu) + } + + /** + * This method is called on backPressed of anyFragment in the activity. + * If condition is called when mediaDetailFragment is opened. + */ + @Deprecated("This method has been deprecated in favor of using the" + + "{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." + + "The OnBackPressedDispatcher controls how back button events are dispatched" + + "to one or more {@link OnBackPressedCallback} objects.") + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount == 1) { + binding.tabLayout.visibility = View.VISIBLE + binding.viewPager.visibility = View.VISIBLE + binding.mediaContainer.visibility = View.GONE + } + super.onBackPressed() + } + + /** + * This method is called on success of API call for Images inside a category. + * The viewpager will notified that number of items have changed. + */ + override fun viewPagerNotifyDataSetChanged() { + mediaDetails?.notifyDataSetChanged() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt new file mode 100644 index 000000000..a50f25669 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt @@ -0,0 +1,109 @@ +package fr.free.nrw.commons.category + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao +import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModal for [CategoryDetailsActivity] + */ +class CategoryDetailsViewModel( + private val bookmarkCategoriesDao: BookmarkCategoriesDao +) : ViewModel() { + + private val _bookmarkState = MutableStateFlow(false) + val bookmarkState = _bookmarkState.asStateFlow() + + + /** + * Used to check if bookmark exists for the given category in DB + * based on that bookmark state is updated + * @param categoryName + */ + fun onCheckIfBookmarked(categoryName: String) { + viewModelScope.launch { + val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName) + _bookmarkState.update { + isBookmarked + } + } + } + + /** + * Handles event when bookmark button is clicked from view + * based on that category is bookmarked or removed in/from in the DB + * and bookmark state is update as well + * @param categoryName + */ + fun onBookmarkClick(categoryName: String) { + if (_bookmarkState.value) { + deleteBookmark(categoryName) + _bookmarkState.update { + false + } + } else { + addBookmark(categoryName) + _bookmarkState.update { + true + } + } + } + + + /** + * Add bookmark into DB + * + * @param categoryName + */ + private fun addBookmark(categoryName: String) { + viewModelScope.launch { + val categoryItem = BookmarksCategoryModal( + categoryName = categoryName + ) + + bookmarkCategoriesDao.insert(categoryItem) + } + } + + + /** + * Delete bookmark from DB + * + * @param categoryName + */ + private fun deleteBookmark(categoryName: String) { + viewModelScope.launch { + bookmarkCategoriesDao.delete( + BookmarksCategoryModal( + categoryName = categoryName + ) + ) + } + } + + /** + * View model factory to create [CategoryDetailsViewModel] + * + * @property bookmarkCategoriesDao + * @constructor Create empty View model factory + */ + class ViewModelFactory @Inject constructor( + private val bookmarkCategoriesDao: BookmarkCategoriesDao + ) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) { + CategoryDetailsViewModel(bookmarkCategoriesDao) as T + } else { + throw IllegalArgumentException("Unknown class name") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt new file mode 100644 index 000000000..22cb19172 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt @@ -0,0 +1,144 @@ +package fr.free.nrw.commons.category + +import android.content.Context +import android.content.Intent +import android.net.Uri +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.notification.NotificationHelper +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Observable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Named +import timber.log.Timber + + +class CategoryEditHelper @Inject constructor( + private val notificationHelper: NotificationHelper, + @Named("commons-page-edit") val pageEditClient: PageEditClient, + private val viewUtil: ViewUtilWrapper, + @Named("username") private val username: String +) { + + /** + * Public interface to edit categories + * @param context + * @param media + * @param categories + * @return + */ + fun makeCategoryEdit( + context: Context, + media: Media, + categories: List, + wikiText: String + ): Single { + viewUtil.showShortToast( + context, + context.getString(R.string.category_edit_helper_make_edit_toast) + ) + return addCategory(media, categories, wikiText) + .flatMapSingle { result -> + Single.just(showCategoryEditNotification(context, media, result)) + } + .firstOrError() + } + + /** + * Rebuilds the WikiText with new categories and post it on server + * + * @param media + * @param categories to be added + * @return + */ + private fun addCategory( + media: Media, + categories: List?, + wikiText: String + ): Observable { + Timber.d("thread is category adding %s", Thread.currentThread().name) + val summary = "Adding categories" + val buffer = StringBuilder() + + // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category" + val wikiTextWithoutCategory: String = when { + wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized")) + wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category")) + else -> "" + } + + if (!categories.isNullOrEmpty()) { + // If the categories list is empty, when reading the categories of a picture, + // the code will add "None selected" to categories list in order to see in picture's categories with "None selected". + // So that after selecting some category, "None selected" should be removed from list + for (category in categories) { + if (category != "None selected" || !wikiText.contains("Uncategorized")) { + buffer.append("[[Category:").append(category).append("]]\n") + } + } + categories.dropWhile { + it == "None selected" + } + } else { + buffer.append("{{subst:unc}}") + } + + val appendText = wikiTextWithoutCategory + buffer + return pageEditClient.edit(media.filename!!, "$appendText\n", summary) + } + + private fun showCategoryEditNotification( + context: Context, + media: Media, + result: Boolean + ): Boolean { + val title: String + val message: String + + if (result) { + title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + + context.getString(R.string.category_edit_helper_show_edit_title_success) + + val categoriesInMessage = StringBuilder() + val mediaCategoryList = media.categories + for ((index, category) in mediaCategoryList?.withIndex()!!) { + categoriesInMessage.append(category) + if (index != mediaCategoryList.size - 1) { + categoriesInMessage.append(",") + } + } + + message = context.resources.getQuantityString( + R.plurals.category_edit_helper_show_edit_message_if, + mediaCategoryList.size, + categoriesInMessage.toString() + ) + } else { + title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + + context.getString(R.string.category_edit_helper_show_edit_title) + message = context.getString(R.string.category_edit_helper_edit_message_else) + } + + val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}" + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)) + notificationHelper.showNotification( + context, + title, + message, + NOTIFICATION_EDIT_CATEGORY, + browserIntent + ) + return result + } + + interface Callback { + fun updateCategoryDisplay(categories: List?): Boolean + } + + companion object { + const val NOTIFICATION_EDIT_CATEGORY = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt new file mode 100644 index 000000000..9fe811f74 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.category + +interface CategoryImagesCallback { + fun viewPagerNotifyDataSetChanged() + + fun onMediaClicked(position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt new file mode 100644 index 000000000..3888ef889 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt @@ -0,0 +1,90 @@ +package fr.free.nrw.commons.category + +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Single +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.QueryMap + +/** + * Interface for interacting with Commons category related APIs + */ +interface CategoryInterface { + /** + * Searches for categories with the specified name. + * + * @param filter The string to be searched + * @param itemLimit How many results are returned + * @return + */ + @GET( + "w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14", + ) + fun searchCategories( + @Query("gsrsearch") filter: String?, + @Query("gsrlimit") itemLimit: Int, + @Query("gsroffset") offset: Int, + ): Single + + /** + * Searches for categories starting with the specified prefix. + * + * @param prefix The string to be searched + * @param itemLimit How many results are returned + * @return + */ + @GET( + "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", + ) + fun searchCategoriesForPrefix( + @Query("gacprefix") prefix: String?, + @Query("gaclimit") itemLimit: Int, + @Query("gacoffset") offset: Int, + ): Single + + /** + * Fetches categories starting and ending with a specified name. + * + * @param startingCategory Name of the category to start + * @param endingCategory Name of the category to end + * @param itemLimit How many categories to return + * @param offset offset + * @return MwQueryResponse + */ + @GET( + "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", + ) + fun getCategoriesByName( + @Query("gacfrom") startingCategory: String?, + @Query("gacto") endingCategory: String?, + @Query("gaclimit") itemLimit: Int, + @Query("gacoffset") offset: Int, + ): Single + + /** + * Fetches non-hidden categories by titles. + * + * @param titles titles to fetch categories for (e.g. File:) + * @param itemLimit How many categories to return + * @return MwQueryResponse + */ + @GET( + "w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden", + ) + fun getCategoriesByTitles( + @Query("titles") titles: String?, + @Query("gcllimit") itemLimit: Int, + ): Single + + @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") + fun getSubCategoryList( + @Query("gcmtitle") categoryName: String, + @QueryMap(encoded = true) continuation: Map, + ): Single + + @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") + fun getParentCategoryList( + @Query("titles") categoryName: String?, + @QueryMap(encoded = true) continuation: Map, + ): Single +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt new file mode 100644 index 000000000..d0ee8d53c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.category + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CategoryItem( + val name: String, + val description: String?, + val thumbnail: String?, + var isSelected: Boolean, +) : Parcelable { + override fun toString(): String = "CategoryItem: '$name'" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CategoryItem + + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int = name.hashCode() +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt new file mode 100644 index 000000000..0322cd7b6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt @@ -0,0 +1,63 @@ +package fr.free.nrw.commons.category + +import io.reactivex.Single + +abstract class ContinuationClient { + private val continuationStore: MutableMap?> = mutableMapOf() + private val continuationExists: MutableMap = mutableMapOf() + + private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true + + fun continuationRequest( + prefix: String, + name: String, + requestFunction: (Map) -> Single, + ): Single> { + val key = "$prefix$name" + return if (hasMorePagesFor(key)) { + responseMapper(requestFunction(continuationStore[key] ?: emptyMap()), key) + } else { + Single.just(emptyList()) + } + } + + abstract fun responseMapper( + networkResult: Single, + key: String? = null, + ): Single> + + fun handleContinuationResponse( + continuation: Map?, + key: String?, + ) { + if (key != null) { + continuationExists[key] = + continuation?.let { continuation -> + continuationStore[key] = continuation + true + } ?: false + } + } + + protected fun resetContinuation( + prefix: String, + category: String, + ) { + continuationExists.remove("$prefix$category") + continuationStore.remove("$prefix$category") + } + + /** + * Remove the existing the key from continuationExists and continuationStore + * + * @param prefix + * @param userName the username + */ + protected fun resetUserContinuation( + prefix: String, + userName: String, + ) { + continuationExists.remove("$prefix$userName") + continuationStore.remove("$prefix$userName") + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt new file mode 100644 index 000000000..0198c61a5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -0,0 +1,106 @@ +package fr.free.nrw.commons.category + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R + + +/** + * This is created to only display UI implementation. Needs to be changed in real implementation + */ +class GridViewAdapter( + context: Context, + layoutResourceId: Int, + private var data: MutableList? +) : ArrayAdapter(context, layoutResourceId, data ?: mutableListOf()) { + + /** + * Adds more items to the list + * It's triggered on scrolling down in the list + * @param images + */ + fun addItems(images: List) { + if (data == null) { + data = mutableListOf() + } + data?.addAll(images) + notifyDataSetChanged() + } + + /** + * Checks the first item in the new list with the old list and returns true if they are the same + * It's triggered on a successful response of the fetch images API. + * @param images + */ + fun containsAll(images: List?): Boolean { + if (images.isNullOrEmpty()) { + return false + } + if (data.isNullOrEmpty()) { + data = mutableListOf() + return false + } + val fileName = data?.get(0)?.filename + val imageName = images[0].filename + return imageName == fileName + } + + override fun isEmpty(): Boolean { + return data.isNullOrEmpty() + } + + /** + * Sets up the UI for the category image item + * @param position + * @param convertView + * @param parent + * @return + */ + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate( + R.layout.layout_category_images, + parent, + false + ) + + val item = data?.get(position) + val imageView = view.findViewById(R.id.categoryImageView) + val fileName = view.findViewById(R.id.categoryImageTitle) + val uploader = view.findViewById(R.id.categoryImageAuthor) + + item?.let { + fileName.text = it.mostRelevantCaption + setUploaderView(it, uploader) + imageView.setImageURI(it.thumbUrl) + } + + return view + } + + /** + * @return the Media item at the given position + */ + override fun getItem(position: Int): Media? { + return data?.get(position) + } + + /** + * Shows author information if it's present + * @param item + * @param uploader + */ + @SuppressLint("StringFormatInvalid") + private fun setUploaderView(item: Media, uploader: TextView) { + uploader.text = context.getString( + R.string.image_uploaded_by, + item.getAuthorOrUser() + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt new file mode 100644 index 000000000..68200992c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface OnCategoriesSaveHandler { + fun onCategoriesSave(categories: List) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt new file mode 100644 index 000000000..378a98893 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.concurrency + +import fr.free.nrw.commons.BuildConfig + + +class BackgroundPoolExceptionHandler : ExceptionHandler { + /** + * If an exception occurs on a background thread, this handler will crash for debug builds + * but fail silently for release builds. + * @param t + */ + override fun onException(t: Throwable) { + // Crash for debug build + if (BuildConfig.DEBUG) { + val thread = Thread { + throw RuntimeException(t) + } + thread.start() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt new file mode 100644 index 000000000..7605964bd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt @@ -0,0 +1,40 @@ +package fr.free.nrw.commons.concurrency + +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadFactory + + +class ExceptionAwareThreadPoolExecutor( + corePoolSize: Int, + threadFactory: ThreadFactory, + private val exceptionHandler: ExceptionHandler? +) : ScheduledThreadPoolExecutor(corePoolSize, threadFactory) { + + override fun afterExecute(r: Runnable, t: Throwable?) { + super.afterExecute(r, t) + var throwable = t + + if (throwable == null && r is Future<*>) { + try { + if (r.isDone) { + r.get() + } + } catch (_: CancellationException) { + // ignore + } catch (_: InterruptedException) { + // ignore + } catch (e: ExecutionException) { + throwable = e.cause ?: e + } catch (e: Exception) { + throwable = e + } + } + + throwable?.let { + exceptionHandler?.onException(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt new file mode 100644 index 000000000..6b3d2a0f7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.concurrency + +interface ExceptionHandler { + + fun onException(t: Throwable) + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt new file mode 100644 index 000000000..46138d676 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt @@ -0,0 +1,122 @@ +package fr.free.nrw.commons.concurrency + +import java.util.concurrent.Callable +import java.util.concurrent.Executor +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit + + +/** + * This class is a thread pool which provides some additional features: + * - it sets the thread priority to a value lower than foreground priority by default, or you can + * supply your own priority + * - it gives you a way to handle exceptions thrown in the thread pool + */ +class ThreadPoolService private constructor(builder: Builder) : Executor { + private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor( + builder.poolSize, + object : ThreadFactory { + private var count = 0 + override fun newThread(r: Runnable): Thread { + count++ + val t = Thread(r, "${builder.name}-$count") + // If the priority is specified out of range, we set the thread priority to + // Thread.MIN_PRIORITY + // It's done to prevent IllegalArgumentException and to prevent setting of + // improper high priority for a less priority task + t.priority = + if ( + builder.priority > Thread.MAX_PRIORITY + || + builder.priority < Thread.MIN_PRIORITY + ) { + Thread.MIN_PRIORITY + } else { + builder.priority + } + return t + } + }, + builder.exceptionHandler + ) + + fun schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture { + return backgroundPool.schedule(callable, time, timeUnit) + } + + fun schedule(runnable: Runnable): ScheduledFuture<*> { + return schedule(runnable, 0, TimeUnit.SECONDS) + } + + fun schedule(runnable: Runnable, time: Long, timeUnit: TimeUnit): ScheduledFuture<*> { + return backgroundPool.schedule(runnable, time, timeUnit) + } + + fun scheduleAtFixedRate( + task: Runnable, + initialDelay: Long, + period: Long, + timeUnit: TimeUnit + ): ScheduledFuture<*> { + return backgroundPool.scheduleWithFixedDelay(task, initialDelay, period, timeUnit) + } + + fun executor(): ScheduledThreadPoolExecutor { + return backgroundPool + } + + fun shutdown() { + backgroundPool.shutdown() + } + + override fun execute(command: Runnable) { + backgroundPool.execute(command) + } + + /** + * Builder class for [ThreadPoolService] + */ + class Builder(val name: String) { + var poolSize: Int = 1 + var priority: Int = Thread.MIN_PRIORITY + var exceptionHandler: ExceptionHandler? = null + + /** + * @param poolSize the number of threads to keep in the pool + * @throws IllegalArgumentException if size of pool <= 0 + */ + fun setPoolSize(poolSize: Int): Builder { + if (poolSize <= 0) { + throw IllegalArgumentException("Pool size must be greater than 0") + } + this.poolSize = poolSize + return this + } + + /** + * @param priority Priority of the threads in the service. You can supply a constant from + * [java.lang.Thread] or + * specify your own priority in the range 1(MIN_PRIORITY) + * to 10(MAX_PRIORITY) + * By default, the priority is set to [java.lang.Thread.MIN_PRIORITY] + */ + fun setPriority(priority: Int): Builder { + this.priority = priority + return this + } + + /** + * @param handler The handler to use to handle exceptions in the service + */ + fun setExceptionHandler(handler: ExceptionHandler): Builder { + exceptionHandler = handler + return this + } + + fun build(): ThreadPoolService { + return ThreadPoolService(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt new file mode 100644 index 000000000..b611574b0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.contributions + +import android.os.Parcel +import android.os.Parcelable +import fr.free.nrw.commons.upload.UploadResult + +data class ChunkInfo( + val uploadResult: UploadResult?, + val indexOfNextChunkToUpload: Int, + val totalChunks: Int, +) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readParcelable(UploadResult::class.java.classLoader), + parcel.readInt(), + parcel.readInt(), + ) { + } + + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { + parcel.writeParcelable(uploadResult, flags) + parcel.writeInt(indexOfNextChunkToUpload) + parcel.writeInt(totalChunks) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt new file mode 100644 index 000000000..d623730ab --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt @@ -0,0 +1,125 @@ +package fr.free.nrw.commons.contributions + +import android.net.Uri +import android.os.Parcelable +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.upload.UploadItem +import fr.free.nrw.commons.upload.UploadMediaDetail +import fr.free.nrw.commons.upload.WikidataPlace +import fr.free.nrw.commons.upload.WikidataPlace.Companion.from +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import kotlinx.parcelize.Parcelize +import java.io.File +import java.util.Date + +@Entity(tableName = "contribution") +@Parcelize +data class Contribution constructor( + @Embedded(prefix = "media_") val media: Media, + @PrimaryKey val pageId: String = media.pageId, + var state: Int = 0, + var transferred: Long = 0, + val decimalCoords: String? = null, + var dateCreatedSource: String? = null, + var wikidataPlace: WikidataPlace? = null, + var chunkInfo: ChunkInfo? = null, + var errorInfo: String? = null, + /** + * @return array list of entityids for the depictions + * + * Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id + * is in turn used to upload depictions to wikibase + */ + val depictedItems: List = ArrayList(), + var mimeType: String? = null, + val localUri: Uri? = null, + var dataLength: Long = 0, + var dateCreated: Date? = null, + var dateCreatedString: String? = null, + var dateModified: Date? = null, + var dateUploadStarted: Date? = null, + var hasInvalidLocation: Int = 0, + var contentUri: Uri? = null, + var countryCode: String? = null, + var imageSHA1: String? = null, + /** + * Number of times a contribution has been retried after a failure + */ + var retries: Int = 0, +) : Parcelable { + fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED) + + constructor( + item: UploadItem, + sessionManager: SessionManager, + depictedItems: List, + categories: List, + imageSHA1: String, + ) : this( + Media( + formatCaptions(item.uploadMediaDetails), + categories, + item.filename, + formatDescriptions(item.uploadMediaDetails), + sessionManager.userName, + sessionManager.userName, + ), + localUri = item.mediaUri, + decimalCoords = item.gpsCoords?.decimalCoords, + dateCreatedSource = "", + depictedItems = depictedItems, + wikidataPlace = from(item.place), + contentUri = item.contentUri, + dateCreatedString = item.fileCreatedDateString, + imageSHA1 = imageSHA1, + ) + + /** + * Set this true when ImageProcessor has said that the location is invalid + * @param hasInvalidLocation + */ + fun setHasInvalidLocation(hasInvalidLocation: Boolean) { + this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0 + } + + fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1 + + companion object { + const val STATE_COMPLETED = -1 + const val STATE_FAILED = 1 + const val STATE_QUEUED = 2 + const val STATE_IN_PROGRESS = 3 + const val STATE_PAUSED = 4 + + /** + * Formatting captions to the Wikibase format for sending labels + * @param uploadMediaDetails list of media Details + */ + fun formatCaptions(uploadMediaDetails: List) = + uploadMediaDetails + .associate { it.languageCode!! to it.captionText } + .filter { it.value.isNotBlank() } + + /** + * Formats the list of descriptions into the format Commons requires for uploads. + * + * @param descriptions the list of descriptions, description is ignored if text is null. + * @return a string with the pattern of {{en|1=descriptionText}} + */ + fun formatDescriptions(descriptions: List) = + descriptions + .filter { !it.descriptionText.isNullOrEmpty() } + .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" } + } + + val fileKey: String? get() = chunkInfo?.uploadResult?.filekey + val localUriPath: File? get() = localUri?.path?.let { File(it) } + + fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload + + fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt new file mode 100644 index 000000000..b5075a21e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt @@ -0,0 +1,122 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.PagedList.BoundaryCallback +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD +import fr.free.nrw.commons.media.MediaClient +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named + +/** + * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that + * is triggered for various boundary conditions in the list + */ +class ContributionBoundaryCallback + @Inject + constructor( + private val repository: ContributionsRepository, + private val sessionManager: SessionManager, + private val mediaClient: MediaClient, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, + ) : BoundaryCallback() { + private val compositeDisposable: CompositeDisposable = CompositeDisposable() + var userName: String? = null + + /** + * It is triggered when the list has no items User's Contributions are then fetched from the + * network + */ + override fun onZeroItemsLoaded() { + refreshList() + } + + /** + * It is triggered when the user scrolls to the top of the list + * */ + override fun onItemAtFrontLoaded(itemAtFront: Contribution) { + } + + /** + * It is triggered when the user scrolls to the end of the list. User's Contributions are then + * fetched from the network + */ + override fun onItemAtEndLoaded(itemAtEnd: Contribution) { + fetchContributions() + } + + /** + * Fetch list from network and save it to local DB. + * + * @param onRefreshFinish callback to invoke when operations finishes + * with either error or success. + */ + fun refreshList(onRefreshFinish: () -> Unit = {}){ + if (sessionManager.userName != null) { + mediaClient.resetUserNameContinuation(sessionManager.userName!!) + } + fetchContributions(onRefreshFinish) + } + + /** + * Fetches contributions using the MediaWiki API + * + * @param onRefreshFinish callback to invoke when operations finishes + * with either error or success. + */ + private fun fetchContributions(onRefreshFinish: () -> Unit = {}) { + if (sessionManager.userName != null) { + userName + ?.let { userName -> + mediaClient + .getMediaListForUser(userName) + .map { mediaList -> + mediaList.map { media -> + Contribution(media = media, state = Contribution.STATE_COMPLETED) + } + }.subscribeOn(ioThreadScheduler) + .subscribe({ list -> + saveContributionsToDB(list, onRefreshFinish) + },{ error -> + onRefreshFinish() + Timber.e( + "Failed to fetch contributions: %s", + error.message, + ) + }) + }?.let { + compositeDisposable.add( + it, + ) + } + } else { + compositeDisposable.clear() + } + } + + /** + * Saves the contributions the the local DB + * + * @param onRefreshFinish callback to invoke when successfully saved to DB. + */ + private fun saveContributionsToDB(contributions: List, onRefreshFinish: () -> Unit) { + compositeDisposable.add( + repository + .save(contributions) + .subscribeOn(ioThreadScheduler) + .subscribe { longs: List? -> + onRefreshFinish() + repository["last_fetch_timestamp"] = System.currentTimeMillis() + }, + ) + } + + /** + * Clean up + */ + fun dispose() { + compositeDisposable.dispose() + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt new file mode 100644 index 000000000..b9532a12e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt @@ -0,0 +1,475 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.LiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import fr.free.nrw.commons.R +import fr.free.nrw.commons.filepicker.DefaultCallback +import fr.free.nrw.commons.filepicker.FilePicker +import fr.free.nrw.commons.filepicker.FilePicker.HandleActivityResult +import fr.free.nrw.commons.filepicker.FilePicker.configuration +import fr.free.nrw.commons.filepicker.FilePicker.handleExternalImagesPicked +import fr.free.nrw.commons.filepicker.FilePicker.onPictureReturnedFromDocuments +import fr.free.nrw.commons.filepicker.FilePicker.openCameraForImage +import fr.free.nrw.commons.filepicker.FilePicker.openCustomSelector +import fr.free.nrw.commons.filepicker.FilePicker.openGallery +import fr.free.nrw.commons.filepicker.UploadableFile +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.location.LocationPermissionsHelper +import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.UploadActivity +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE +import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT +import java.util.Arrays +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ContributionController @Inject constructor(@param:Named("default_preferences") private val defaultKvStore: JsonKvStore) { + private var locationBeforeImageCapture: LatLng? = null + private var isInAppCameraUpload = false + @JvmField + var locationPermissionCallback: LocationPermissionCallback? = null + private var locationPermissionsHelper: LocationPermissionsHelper? = null + + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // LiveData> failedAndPendingContributionList; + @JvmField + var pendingContributionList: LiveData>? = null + @JvmField + var failedContributionList: LiveData>? = null + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var repository: ContributionsRepository? = null + + /** + * Check for permissions and initiate camera click + */ + fun initiateCameraPick( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + resultLauncher: ActivityResultLauncher + ) { + val useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true) + if (!useExtStorage) { + initiateCameraUpload(activity, resultLauncher) + return + } + + checkPermissionsAndPerformAction( + activity, + { + if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { + defaultKvStore.putBoolean("inAppCameraFirstRun", false) + askUserToAllowLocationAccess( + activity, + inAppCameraLocationPermissionLauncher, + resultLauncher + ) + } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { + createDialogsAndHandleLocationPermissions( + activity, + inAppCameraLocationPermissionLauncher, resultLauncher + ) + } else { + initiateCameraUpload(activity, resultLauncher) + } + }, + R.string.storage_permission_title, + R.string.write_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + + /** + * Asks users to provide location access + * + * @param activity + */ + private fun createDialogsAndHandleLocationPermissions( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>?, + resultLauncher: ActivityResultLauncher + ) { + locationPermissionCallback = object : LocationPermissionCallback { + override fun onLocationPermissionDenied(toastMessage: String) { + Toast.makeText( + activity, + toastMessage, + Toast.LENGTH_LONG + ).show() + initiateCameraUpload(activity, resultLauncher) + } + + override fun onLocationPermissionGranted() { + if (!locationPermissionsHelper!!.isLocationAccessToAppsTurnedOn()) { + showLocationOffDialog( + activity, R.string.in_app_camera_needs_location, + R.string.in_app_camera_location_unavailable, resultLauncher + ) + } else { + initiateCameraUpload(activity, resultLauncher) + } + } + } + + locationPermissionsHelper = LocationPermissionsHelper( + activity, locationManager!!, locationPermissionCallback + ) + inAppCameraLocationPermissionLauncher?.launch( + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + } + + /** + * Shows a dialog alerting the user about location services being off and asking them to turn it + * on + * TODO: Add a seperate callback in LocationPermissionsHelper for this. + * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 + * + * @param activity Activity reference + * @param dialogTextResource Resource id of text to be shown in dialog + * @param toastTextResource Resource id of text to be shown in toast + * @param resultLauncher + */ + private fun showLocationOffDialog( + activity: Activity, dialogTextResource: Int, + toastTextResource: Int, resultLauncher: ActivityResultLauncher + ) { + 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, resultLauncher) + } + ) + } + + fun handleShowRationaleFlowCameraLocation( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>?, + resultLauncher: ActivityResultLauncher + ) { + showAlertDialog( + activity, activity.getString(R.string.location_permission_title), + activity.getString(R.string.in_app_camera_location_permission_rationale), + activity.getString(R.string.ok), + activity.getString(R.string.cancel), + { + createDialogsAndHandleLocationPermissions( + activity, + inAppCameraLocationPermissionLauncher, resultLauncher + ) + }, + { + locationPermissionCallback!!.onLocationPermissionDenied( + activity.getString(R.string.in_app_camera_location_permission_denied) + ) + }, + null + ) + } + + /** + * Suggest user to attach location information with pictures. If the user selects "Yes", then: + * + * + * Location is taken from the EXIF if the default camera application does not redact location + * tags. + * + * + * Otherwise, if the EXIF metadata does not have location information, then location captured by + * the app is used + * + * @param activity + */ + private fun askUserToAllowLocationAccess( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + resultLauncher: ActivityResultLauncher + ) { + 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, resultLauncher + ) + }, + { + showLongToast(activity, R.string.in_app_camera_location_permission_denied) + defaultKvStore.putBoolean("inAppCameraLocationPref", false) + initiateCameraUpload(activity, resultLauncher) + }, + null + ) + } + + /** + * Initiate gallery picker + */ + fun initiateGalleryPick( + activity: Activity, + resultLauncher: ActivityResultLauncher, + allowMultipleUploads: Boolean + ) { + initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads) + } + + /** + * Initiate gallery picker with permission + */ + fun initiateCustomGalleryPickWithPermission( + activity: Activity, + resultLauncher: ActivityResultLauncher, + singleSelection: Boolean = false + ) { + setPickerConfiguration(activity, true) + + checkPermissionsAndPerformAction( + activity, + { FilePicker.openCustomSelector(activity, resultLauncher, 0, singleSelection) }, + R.string.storage_permission_title, + R.string.write_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + + + /** + * Open chooser for gallery uploads + */ + private fun initiateGalleryUpload( + activity: Activity, resultLauncher: ActivityResultLauncher, + allowMultipleUploads: Boolean + ) { + setPickerConfiguration(activity, allowMultipleUploads) + openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred) + } + + /** + * Sets configuration for file picker + */ + private fun setPickerConfiguration( + activity: Activity, + allowMultipleUploads: Boolean + ) { + val copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true) + configuration(activity) + .setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage) + .setAllowMultiplePickInGallery(allowMultipleUploads) + } + + /** + * Initiate camera upload by opening camera + */ + private fun initiateCameraUpload( + activity: Activity, + resultLauncher: ActivityResultLauncher + ) { + setPickerConfiguration(activity, false) + if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { + locationBeforeImageCapture = locationManager!!.getLastLocation() + } + isInAppCameraUpload = true + openCameraForImage(activity, resultLauncher, 0) + } + + private val isDocumentPhotoPickerPreferred: Boolean + get() = defaultKvStore.getBoolean( + "openDocumentPhotoPickerPref", true + ) + + fun onPictureReturnedFromGallery( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + if (isDocumentPhotoPickerPreferred) { + onPictureReturnedFromDocuments(result, activity, callbacks) + } else { + FilePicker.onPictureReturnedFromGallery(result, activity, callbacks) + } + } + + fun onPictureReturnedFromCustomSelector( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks) + } + + fun onPictureReturnedFromCamera( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + FilePicker.onPictureReturnedFromCamera(result, activity, callbacks) + } + + /** + * Attaches callback for file picker. + */ + fun handleActivityResultWithCallback( + activity: Activity, + handleActivityResult: HandleActivityResult + ) { + handleActivityResult.onHandleActivityResult(object : DefaultCallback() { + override fun onCanceled(source: FilePicker.ImageSource, type: Int) { + super.onCanceled(source, type) + defaultKvStore.remove(PLACE_OBJECT) + } + + override fun onImagePickerError( + e: Exception, source: FilePicker.ImageSource, + type: Int + ) { + showShortToast(activity, R.string.error_occurred_in_picking_images) + } + + override fun onImagesPicked( + imagesFiles: List, + source: FilePicker.ImageSource, type: Int + ) { + val intent = handleImagesPicked(activity, imagesFiles) + activity.startActivity(intent) + } + }) + } + + fun handleExternalImagesPicked( + activity: Activity, + data: Intent? + ): List { + return handleExternalImagesPicked(data, activity) + } + + /** + * 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 fun handleImagesPicked( + context: Context, + imagesFiles: List + ): Intent { + val shareIntent = Intent(context, UploadActivity::class.java) + shareIntent.setAction(ACTION_INTERNAL_UPLOADS) + shareIntent + .putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, ArrayList(imagesFiles)) + val place = defaultKvStore.getJson(PLACE_OBJECT, Place::class.java) + + if (place != null) { + 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 + } + + val pendingContributions: Unit + /** + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it + * populates the `pendingContributionList`. + */ + get() { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory = repository!!.fetchContributionsWithStates( + Arrays.asList( + Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, + Contribution.STATE_PAUSED + ) + ) + + val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig) + pendingContributionList = livePagedListBuilder.build() + } + + val failedContributions: Unit + /** + * Fetches the contributions with the state "FAILED" and populates the + * `failedContributionList`. + */ + get() { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory = repository!!.fetchContributionsWithStates( + listOf(Contribution.STATE_FAILED) + ) + + val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig) + failedContributionList = livePagedListBuilder.build() + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and + * then it populates the `failedAndPendingContributionList`. + */ + // void getFailedAndPendingContributions() { + // final PagedList.Config pagedListConfig = + // (new PagedList.Config.Builder()) + // .setPrefetchDistance(50) + // .setPageSize(10).build(); + // Factory factory; + // factory = repository.fetchContributionsWithStates( + // Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, + // Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); + // + // LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, + // pagedListConfig); + // failedAndPendingContributionList = livePagedListBuilder.build(); + // } + + companion object { + const val ACTION_INTERNAL_UPLOADS: String = "internalImageUploads" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt new file mode 100644 index 000000000..50faa1340 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt @@ -0,0 +1,148 @@ +package fr.free.nrw.commons.contributions + +import android.database.sqlite.SQLiteException +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.reactivex.Completable +import io.reactivex.Single +import java.util.Calendar + +@Dao +abstract class ContributionDao { + @Query("SELECT * FROM contribution order by media_dateUploaded DESC") + abstract fun fetchContributions(): DataSource.Factory + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun saveSynchronous(contribution: Contribution) + + fun save(contribution: Contribution): Completable { + return Completable + .fromAction { + contribution.dateModified = Calendar.getInstance().time + if (contribution.dateUploadStarted == null) { + contribution.dateUploadStarted = Calendar.getInstance().time + } + saveSynchronous(contribution) + } + } + + @Transaction + open fun deleteAndSaveContribution( + oldContribution: Contribution, + newContribution: Contribution + ) { + deleteSynchronous(oldContribution) + saveSynchronous(newContribution) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun save(contribution: List): Single> + + @Delete + abstract fun deleteSynchronous(contribution: Contribution) + + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @throws SQLiteException If an SQLite error occurs. + */ + @Query("DELETE FROM contribution WHERE state IN (:states)") + @Throws(SQLiteException::class) + abstract fun deleteContributionsWithStatesSynchronous(states: List) + + fun delete(contribution: Contribution): Completable { + return Completable + .fromAction { deleteSynchronous(contribution) } + } + + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsWithStates(states: List): Completable { + return Completable + .fromAction { deleteContributionsWithStatesSynchronous(states) } + } + + @Query("SELECT * from contribution WHERE media_filename=:fileName") + abstract fun getContributionWithTitle(fileName: String): List + + @Query("SELECT * from contribution WHERE pageId=:pageId") + abstract fun getContribution(pageId: String): Contribution + + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + abstract fun getContribution(states: List): Single> + + /** + * Gets contributions with specific states in descending order by the date they were uploaded. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + abstract fun getContributions( + states: List + ): DataSource.Factory + + /** + * Gets contributions with specific states in ascending order by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC") + abstract fun getContributionsSortedByDateUploadStarted( + states: List + ): DataSource.Factory + + @Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)") + abstract fun getPendingUploads(toUpdateStates: IntArray): Single + + @Query("Delete FROM contribution") + @Throws(SQLiteException::class) + abstract fun deleteAll() + + @Update + abstract fun updateSynchronous(contribution: Contribution) + + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + */ + @Query("UPDATE contribution SET state = :newState WHERE state IN (:states)") + abstract fun updateContributionsState(states: List, newState: Int) + + fun update(contribution: Contribution): Completable { + return Completable.fromAction { + contribution.dateModified = Calendar.getInstance().time + updateSynchronous(contribution) + } + } + + + + /** + * Updates the state of contributions with specific states asynchronously. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return Completable + .fromAction { + updateContributionsState(states, newState) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt new file mode 100644 index 000000000..899ef458f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt @@ -0,0 +1,182 @@ +package fr.free.nrw.commons.contributions + +import android.net.Uri +import android.text.TextUtils +import android.view.View +import android.webkit.URLUtil +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.RecyclerView +import com.facebook.imagepipeline.request.ImageRequest +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.utils.MediaAttributionUtil +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +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 +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.io.File + +class ContributionViewHolder internal constructor( + parent: View, + private val callback: ContributionsListAdapter.Callback, + private val compositeDisposable: CompositeDisposable, + private val mediaClient: MediaClient, + private val mediaDataExtractor: MediaDataExtractor +) : RecyclerView.ViewHolder(parent) { + var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent) + + private var position = 0 + private var contribution: Contribution? = null + private var isWikipediaButtonDisplayed = false + private val pausingPopUp: AlertDialog + var imageRequest: ImageRequest? = null + private set + + init { + binding.contributionImage.setOnClickListener { v: View? -> imageClicked() } + binding.wikipediaButton.setOnClickListener { v: View? -> wikipediaButtonClicked() } + + /* Set a dialog indicating that the upload is being paused. This is needed because pausing +an upload might take a dozen seconds. */ + val builder = AlertDialog.Builder( + parent.context + ) + builder.setCancelable(false) + builder.setView(R.layout.progress_dialog) + pausingPopUp = builder.create() + } + + fun init(position: Int, contribution: Contribution?) { + //handling crashes when the contribution is null. + + if (null == contribution) { + return + } + + this.contribution = contribution + this.position = position + binding.contributionTitle.text = contribution.media.mostRelevantCaption + setAuthorText(contribution.media) + + //Removes flicker of loading image. + binding.contributionImage.hierarchy.fadeDuration = 0 + + binding.contributionImage.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) + binding.contributionImage.hierarchy.setFailureImage(R.drawable.image_placeholder) + + val imageSource = chooseImageSource( + contribution.media.thumbUrl, + contribution.localUri + ) + if (!TextUtils.isEmpty(imageSource)) { + if (URLUtil.isHttpsUrl(imageSource)) { + imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) + .setProgressiveRenderingEnabled(true) + .build() + } else if (URLUtil.isFileUrl(imageSource)) { + imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)) + } else if (imageSource != null) { + val file = File(imageSource) + imageRequest = ImageRequest.fromFile(file) + } + + if (imageRequest != null) { + binding.contributionImage.setImageRequest(imageRequest) + } + } + + binding.contributionSequenceNumber.text = (position + 1).toString() + binding.contributionSequenceNumber.visibility = View.VISIBLE + binding.wikipediaButton.visibility = View.GONE + binding.contributionState.visibility = View.GONE + binding.contributionProgress.visibility = View.GONE + binding.imageOptions.visibility = View.GONE + binding.contributionState.text = "" + checkIfMediaExistsOnWikipediaPage(contribution) + } + + fun updateAttribution() { + if (contribution != null) { + val media = contribution!!.media + if (!media.getAttributedAuthor().isNullOrEmpty()) { + return + } + compositeDisposable.addAll( + mediaDataExtractor.fetchCreatorIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndLabels -> + media.creatorName = MediaAttributionUtil.getCreatorName(idAndLabels) + setAuthorText(media) + }, + { t: Throwable? -> Timber.e(t) }) + ) + } + } + + private fun setAuthorText(media: Media) { + binding.authorView.text = MediaAttributionUtil.getTagLine(media, itemView.context) + } + + /** + * Checks if a media exists on the corresponding Wikipedia article Currently the check is made + * for the device's current language Wikipedia + * + * @param contribution + */ + private fun checkIfMediaExistsOnWikipediaPage(contribution: Contribution) { + if (contribution.wikidataPlace == null + || contribution.wikidataPlace!!.wikipediaArticle == null + ) { + return + } + val wikipediaArticle = contribution.wikidataPlace!!.getWikipediaPageTitle() + compositeDisposable.add( + mediaClient.doesPageContainMedia(wikipediaArticle) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { mediaExists: Boolean -> + displayWikipediaButton(mediaExists) + }) + } + + /** + * Handle action buttons visibility if the corresponding wikipedia page doesn't contain any + * media. This method needs to control the state of just the scenario where media does not + * exists as other scenarios are already handled in the init method. + * + * @param mediaExists + */ + private fun displayWikipediaButton(mediaExists: Boolean) { + if (!mediaExists) { + binding.wikipediaButton.visibility = View.VISIBLE + isWikipediaButtonDisplayed = true + binding.imageOptions.visibility = View.VISIBLE + } + } + + /** + * Returns the image source for the image view, first preference is given to thumbUrl if that is + * null, moves to local uri and if both are null return null + * + * @param thumbUrl + * @param localUri + * @return + */ + private fun chooseImageSource(thumbUrl: String?, localUri: Uri?): String? { + return if (!TextUtils.isEmpty(thumbUrl)) thumbUrl else localUri?.toString() + } + + fun imageClicked() { + callback.openMediaDetail(position, isWikipediaButtonDisplayed) + } + + fun wikipediaButtonClicked() { + callback.addImageToWikipedia(contribution) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt new file mode 100644 index 000000000..0e039729f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import fr.free.nrw.commons.BasePresenter + +/** + * The contract for Contributions View & Presenter + */ +interface ContributionsContract { + + interface View { + fun getContext(): Context? + } + + interface UserActionListener : BasePresenter { + fun getContributionsWithTitle(uri: String): Contribution + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt new file mode 100644 index 000000000..537d805c5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt @@ -0,0 +1,997 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +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 +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.CompoundButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import androidx.work.WorkInfo +import androidx.work.WorkManager +import fr.free.nrw.commons.MapController.NearbyPlacesInfo +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.campaigns.CampaignView +import fr.free.nrw.commons.campaigns.CampaignsPresenter +import fr.free.nrw.commons.campaigns.ICampaignsView +import fr.free.nrw.commons.campaigns.models.Campaign +import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment +import fr.free.nrw.commons.databinding.FragmentContributionsBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +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.location.LocationUpdateListener +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailProvider +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.nearby.NearbyController +import fr.free.nrw.commons.nearby.NearbyNotificationCardView +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +import fr.free.nrw.commons.notification.NotificationController +import fr.free.nrw.commons.notification.models.Notification +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.UploadWorker +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.LengthUtils.computeBearing +import fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween +import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished +import fr.free.nrw.commons.utils.PermissionUtils.hasPermission +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import fr.free.nrw.commons.utils.isMonumentsEnabled +import fr.free.nrw.commons.utils.wLMEndDate +import fr.free.nrw.commons.utils.wLMStartDate +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + +class ContributionsFragment : CommonsDaggerSupportFragment(), FragmentManager.OnBackStackChangedListener, + LocationUpdateListener, MediaDetailProvider, SensorEventListener, ICampaignsView, + ContributionsContract.View, ContributionsListFragment.Callback { + @JvmField + @Inject + @Named("default_preferences") + var store: JsonKvStore? = null + + @JvmField + @Inject + var nearbyController: NearbyController? = null + + @JvmField + @Inject + var okHttpJsonApiClient: OkHttpJsonApiClient? = null + + @JvmField + @Inject + var presenter: CampaignsPresenter? = null + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var contributionController: ContributionController? = null + + override var compositeDisposable: CompositeDisposable = CompositeDisposable() + + private var contributionsListFragment: ContributionsListFragment? = null + + // Getter for mediaDetailPagerFragment + var mediaDetailPagerFragment: MediaDetailPagerFragment? = null + private set + var binding: FragmentContributionsBinding? = null + + @JvmField + @Inject + var contributionsPresenter: ContributionsPresenter? = null + + @JvmField + @Inject + var sessionManager: SessionManager? = null + + private var currentLatLng: LatLng? = null + + private var isFragmentAttachedBefore = false + private var checkBoxView: View? = null + private var checkBox: CheckBox? = null + + var notificationCount: TextView? = null + + var pendingUploadsCountTextView: TextView? = null + + var uploadsErrorTextView: TextView? = null + + var pendingUploadsImageView: ImageView? = null + + private var wlmCampaign: Campaign? = null + + private var userName: String? = null + private var isUserProfile = false + + private var mSensorManager: SensorManager? = null + private var mLight: Sensor? = null + private var direction = 0f + private val nearbyLocationPermissionLauncher = + registerForActivityResult, Map>( + ActivityResultContracts.RequestMultiplePermissions(), + object : ActivityResultCallback> { + override fun onActivityResult(result: Map) { + var areAllGranted = true + for (b in result.values) { + areAllGranted = areAllGranted && b + } + + if (areAllGranted) { + onLocationPermissionGranted() + } else { + if (shouldShowRequestPermissionRationale( + permission.ACCESS_FINE_LOCATION + ) + && store!!.getBoolean("displayLocationPermissionForCardView", true) + && !store!!.getBoolean("doNotAskForLocationPermission", false) + && ((activity as MainActivity).activeFragment + == ActiveFragment.CONTRIBUTIONS) + ) { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION + } else { + displayYouWontSeeNearbyMessage() + } + } + } + }) + + private var shouldShowMediaDetailsFragment = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments != null && requireArguments().getString(ProfileActivity.KEY_USERNAME) != null) { + userName = requireArguments().getString(ProfileActivity.KEY_USERNAME) + isUserProfile = true + } + mSensorManager = requireActivity().getSystemService(Context.SENSOR_SERVICE) as SensorManager + mLight = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ORIENTATION) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentContributionsBinding.inflate(inflater, container, false) + + initWLMCampaign() + presenter!!.onAttachView(this) + contributionsPresenter!!.onAttachView(this) + binding!!.campaignsView.visibility = View.GONE + checkBoxView = View.inflate(activity, R.layout.nearby_permission_dialog, null) + checkBox = checkBoxView?.findViewById(R.id.never_ask_again) as CheckBox + checkBox!!.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean -> + if (isChecked) { + // Do not ask for permission on activity start again + store!!.putBoolean("displayLocationPermissionForCardView", false) + } + } + + if (savedInstanceState != null) { + mediaDetailPagerFragment = childFragmentManager + .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG) as MediaDetailPagerFragment? + contributionsListFragment = childFragmentManager + .findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG) as ContributionsListFragment? + shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible") + } + + initFragments() + if (!isUserProfile) { + upDateUploadCount() + } + if (shouldShowMediaDetailsFragment) { + showMediaDetailPagerFragment() + } else { + if (mediaDetailPagerFragment != null) { + removeFragment(mediaDetailPagerFragment!!) + } + showContributionsListFragment() + } + + if (!isBetaFlavour && sessionManager!!.isUserLoggedIn + && sessionManager!!.currentAccount != null && !isUserProfile + ) { + setUploadCount() + } + setHasOptionsMenu(true) + return binding!!.root + } + + /** + * Initialise the campaign object for WML + */ + private fun initWLMCampaign() { + wlmCampaign = Campaign( + getString(R.string.wlm_campaign_title), + getString(R.string.wlm_campaign_description), wLMStartDate, + wLMEndDate, NearbyParentFragment.WLM_URL, true + ) + } + + override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater + ) { + // Removing contributions menu items for ProfileActivity + + if (activity is ProfileActivity) { + return + } + + inflater.inflate(R.menu.contribution_activity_notification_menu, menu) + + val notificationsMenuItem = menu.findItem(R.id.notifications) + val notification = notificationsMenuItem.actionView + notificationCount = notification!!.findViewById(R.id.notification_count_badge) + val uploadMenuItem = menu.findItem(R.id.upload_tab) + val uploadMenuItemActionView = uploadMenuItem.actionView + pendingUploadsCountTextView = uploadMenuItemActionView!!.findViewById( + R.id.pending_uploads_count_badge + ) + uploadsErrorTextView = uploadMenuItemActionView.findViewById( + R.id.uploads_error_count_badge + ) + pendingUploadsImageView = uploadMenuItemActionView.findViewById( + R.id.pending_uploads_image_view + ) + if (pendingUploadsImageView != null) { + pendingUploadsImageView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + if (pendingUploadsCountTextView != null) { + pendingUploadsCountTextView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + if (uploadsErrorTextView != null) { + uploadsErrorTextView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + notification.setOnClickListener { view: View? -> + context?.let { + startYourself( + it, "unread" + ) + } + } + } + + @SuppressLint("CheckResult") + fun setNotificationCount() { + compositeDisposable.add( + notificationController!!.getNotifications(false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { notificationList: List -> + this.initNotificationViews( + notificationList + ) + }, + { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while loading notifications" + ) + }) + ) + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Sets the visibility of the upload icon based on the number of failed and pending + * contributions. + */ + // public void setUploadIconVisibility() { + // contributionController.getFailedAndPendingContributions(); + // contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), + // list -> { + // updateUploadIcon(list.size()); + // }); + // } + /** + * Sets the count for the upload icon based on the number of pending and failed contributions. + */ + fun setUploadIconCount() { + contributionController!!.pendingContributions + contributionController!!.pendingContributionList!!.observe( + viewLifecycleOwner, + Observer> { list: PagedList -> + updatePendingIcon(list.size) + }) + contributionController!!.failedContributions + contributionController!!.failedContributionList!!.observe( + viewLifecycleOwner, + Observer> { list: PagedList -> + updateErrorIcon(list.size) + }) + } + + fun scrollToTop() { + if (contributionsListFragment != null) { + contributionsListFragment!!.scrollToTop() + } + } + + private fun initNotificationViews(notificationList: List) { + Timber.d("Number of notifications is %d", notificationList.size) + if (notificationList.isEmpty()) { + notificationCount!!.visibility = View.GONE + } else { + notificationCount!!.visibility = View.VISIBLE + notificationCount!!.text = notificationList.size.toString() + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + /* + - There are some operations we need auth, so we need to make sure isAuthCookieAcquired. + - And since we use same retained fragment doesn't want to make all network operations + all over again on same fragment attached to recreated activity, we do this network + operations on first time fragment attached to an activity. Then they will be retained + until fragment life time ends. + */ + if (!isFragmentAttachedBefore && activity != null) { + isFragmentAttachedBefore = true + } + } + + /** + * Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates + * new one if null. + */ + private fun showContributionsListFragment() { + // show nearby card view on contributions list is visible + if (binding!!.cardViewNearby != null && !isUserProfile) { + if (store!!.getBoolean("displayNearbyCardView", true)) { + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + binding!!.cardViewNearby.visibility = View.GONE + } + } + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + } + + private fun showMediaDetailPagerFragment() { + // hide nearby card view on media detail is visible + setupViewForMediaDetails() + showFragment( + mediaDetailPagerFragment!!, MEDIA_DETAIL_PAGER_FRAGMENT_TAG, + contributionsListFragment + ) + } + + private fun setupViewForMediaDetails() { + if (binding != null) { + binding!!.campaignsView.visibility = View.GONE + } + } + + override fun onBackStackChanged() { + fetchCampaigns() + } + + private fun initFragments() { + if (null == contributionsListFragment) { + contributionsListFragment = ContributionsListFragment() + val contributionsListBundle = Bundle() + contributionsListBundle.putString(ProfileActivity.KEY_USERNAME, userName) + contributionsListFragment!!.arguments = contributionsListBundle + } + + if (shouldShowMediaDetailsFragment) { + showMediaDetailPagerFragment() + } else { + showContributionsListFragment() + } + + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + } + + /** + * Replaces the root frame layout with the given fragment + * + * @param fragment + * @param tag + * @param otherFragment + */ + private fun showFragment(fragment: Fragment, tag: String, otherFragment: Fragment?) { + val transaction = childFragmentManager.beginTransaction() + if (fragment.isAdded && otherFragment != null) { + transaction.hide(otherFragment) + transaction.show(fragment) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (fragment.isAdded && otherFragment == null) { + transaction.show(fragment) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (!fragment.isAdded && otherFragment != null) { + transaction.hide(otherFragment) + transaction.add(R.id.root_frame, fragment, tag) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (!fragment.isAdded) { + transaction.replace(R.id.root_frame, fragment, tag) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } + } + + fun removeFragment(fragment: Fragment) { + childFragmentManager + .beginTransaction() + .remove(fragment) + .commit() + childFragmentManager.executePendingTransactions() + } + + private fun setUploadCount() { + okHttpJsonApiClient + ?.getUploadCount(sessionManager?.currentAccount!!.name) + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread())?.let { + compositeDisposable.add( + it + .subscribe( + { uploadCount: Int -> this.displayUploadCount(uploadCount) }, + { t: Throwable? -> Timber.e(t, "Fetching upload count failed") } + )) + } + } + + private fun displayUploadCount(uploadCount: Int) { + if (requireActivity().isFinishing + || resources == null + ) { + return + } + + (activity as MainActivity).setNumOfUploads(uploadCount) + } + + override fun onPause() { + super.onPause() + locationManager!!.removeLocationListener(this) + locationManager!!.unregisterLocationManager() + mSensorManager!!.unregisterListener(this) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + } + + override fun onResume() { + super.onResume() + contributionsPresenter!!.onAttachView(this) + locationManager!!.addLocationListener(this) + + if (binding == null) { + return + } + + binding!!.cardViewNearby.permissionRequestButton.setOnClickListener { v: View? -> + showNearbyCardPermissionRationale() + } + + // Notification cards should only be seen on contributions list, not in media details + if (mediaDetailPagerFragment == null && !isUserProfile) { + if (store!!.getBoolean("displayNearbyCardView", true)) { + checkPermissionsAndShowNearbyCardView() + + // Calling nearby card to keep showing it even when user clicks on it and comes back + try { + updateClosestNearbyCardViewInfo() + } catch (e: Exception) { + Timber.e(e) + } + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + // Hide nearby notification card view if related shared preferences is false + binding!!.cardViewNearby.visibility = View.GONE + } + + // Notification Count and Campaigns should not be set, if it is used in User Profile + if (!isUserProfile) { + setNotificationCount() + fetchCampaigns() + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // setUploadIconVisibility(); + setUploadIconCount() + } + } + mSensorManager!!.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI) + } + + private fun checkPermissionsAndShowNearbyCardView() { + if (hasPermission( + requireActivity(), + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + ) { + onLocationPermissionGranted() + } else if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION) + && store!!.getBoolean("displayLocationPermissionForCardView", true) + && !store!!.getBoolean("doNotAskForLocationPermission", false) + && ((activity as MainActivity).activeFragment == ActiveFragment.CONTRIBUTIONS) + ) { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION + showNearbyCardPermissionRationale() + } + } + + private fun requestLocationPermission() { + nearbyLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION)) + } + + private fun onLocationPermissionGranted() { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED + locationManager!!.registerLocationManager() + } + + private fun showNearbyCardPermissionRationale() { + showAlertDialog( + requireActivity(), + getString(R.string.nearby_card_permission_title), + getString(R.string.nearby_card_permission_explanation), + { this.requestLocationPermission() }, + { this.displayYouWontSeeNearbyMessage() }, + checkBoxView + ) + } + + private fun displayYouWontSeeNearbyMessage() { + showLongToast( + requireActivity(), + resources.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 fun updateClosestNearbyCardViewInfo() { + currentLatLng = locationManager!!.getLastLocation() + compositeDisposable.add(Observable.fromCallable { + nearbyController?.loadAttractionsFromLocation( + currentLatLng, currentLatLng, true, + false + ) + } // thanks to boolean, it will only return closest result + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { nearbyPlacesInfo: NearbyPlacesInfo? -> + this.updateNearbyNotification( + nearbyPlacesInfo + ) + }, + { throwable: Throwable? -> + Timber.d(throwable) + updateNearbyNotification(null) + }) + ) + } + + private fun updateNearbyNotification( + nearbyPlacesInfo: NearbyPlacesInfo? + ) { + if (nearbyPlacesInfo?.placeList != null && nearbyPlacesInfo.placeList.size > 0) { + var closestNearbyPlace: Place? = null + // Find the first nearby place that has no image and exists + for (place in nearbyPlacesInfo.placeList) { + if (place.pic == "" && place.exists) { + closestNearbyPlace = place + break + } + } + + if (closestNearbyPlace == null) { + binding!!.cardViewNearby.visibility = View.GONE + } else { + val distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location) + closestNearbyPlace.setDistance(distance) + direction = computeBearing(currentLatLng!!, closestNearbyPlace.location).toFloat() + binding!!.cardViewNearby.updateContent(closestNearbyPlace) + } + } else { + // Means that no close nearby place is found + binding!!.cardViewNearby.visibility = 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) { + binding!!.cardViewNearby.visibility = View.GONE + } + } + + override fun onDestroy() { + try { + compositeDisposable.clear() + childFragmentManager.removeOnBackStackChangedListener(this) + locationManager!!.unregisterLocationManager() + locationManager!!.removeLocationListener(this) + super.onDestroy() + } catch (exception: IllegalArgumentException) { + Timber.e(exception) + } catch (exception: IllegalStateException) { + Timber.e(exception) + } + } + + override fun onLocationChangedSignificantly(latLng: LatLng) { + // Will be called if location changed more than 1000 meter + updateClosestNearbyCardViewInfo() + } + + override fun onLocationChangedSlightly(latLng: LatLng) { + /* Update closest nearby notification card onLocationChangedSlightly + */ + try { + updateClosestNearbyCardViewInfo() + } catch (e: Exception) { + Timber.e(e) + } + } + + override fun onLocationChangedMedium(latLng: LatLng) { + // Update closest nearby card view if location changed more than 500 meters + updateClosestNearbyCardViewInfo() + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { + super.onViewCreated(view, savedInstanceState) + } + + /** + * As the home screen has limited space, we have choosen to show either campaigns or WLM card. + * The WLM Card gets the priority over monuments, so if the WLM is going on we show that instead + * of campaigns on the campaigns card + */ + private fun fetchCampaigns() { + if (isMonumentsEnabled) { + if (binding != null) { + binding!!.campaignsView.setCampaign(wlmCampaign) + binding!!.campaignsView.visibility = View.VISIBLE + } + } else if (store!!.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) { + presenter!!.getCampaigns() + } else { + if (binding != null) { + binding!!.campaignsView.visibility = View.GONE + } + } + } + + override fun showCampaigns(campaign: Campaign?) { + if (campaign != null && !isUserProfile) { + if (binding != null) { + binding!!.campaignsView.setCampaign(campaign) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + presenter!!.onDetachView() + } + + override fun notifyDataSetChanged() { + if (mediaDetailPagerFragment != null) { + mediaDetailPagerFragment!!.notifyDataSetChanged() + } + } + + /** + * Notify the viewpager that number of items have changed. + */ + override fun viewPagerNotifyDataSetChanged() { + if (mediaDetailPagerFragment != null) { + mediaDetailPagerFragment!!.notifyDataSetChanged() + } + } + + /** + * Updates the visibility and text of the pending uploads count TextView based on the given + * count. + * + * @param pendingCount The number of pending uploads. + */ + fun updatePendingIcon(pendingCount: Int) { + if (pendingUploadsCountTextView != null) { + if (pendingCount != 0) { + pendingUploadsCountTextView!!.visibility = View.VISIBLE + pendingUploadsCountTextView!!.text = pendingCount.toString() + } else { + pendingUploadsCountTextView!!.visibility = View.INVISIBLE + } + } + } + + /** + * Updates the visibility and text of the error uploads TextView based on the given count. + * + * @param errorCount The number of error uploads. + */ + fun updateErrorIcon(errorCount: Int) { + if (uploadsErrorTextView != null) { + if (errorCount != 0) { + uploadsErrorTextView!!.visibility = View.VISIBLE + uploadsErrorTextView!!.text = errorCount.toString() + } else { + uploadsErrorTextView!!.visibility = View.GONE + } + } + } + + // /** + // * Temporarily disabled. See issue [#5847](https://github.com/commons-app/apps-android-commons/issues/5847) + // * @param count The number of pending uploads. + // */ + // public void updateUploadIcon(int count) { + // public void updateUploadIcon(int count) { + // if (pendingUploadsImageView != null) { + // if (count != 0) { + // pendingUploadsImageView.setVisibility(View.VISIBLE); + // } else { + // pendingUploadsImageView.setVisibility(View.GONE); + // } + // } + // } + /** + * Replace whatever is in the current contributionsFragmentContainer view with + * mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects + * a contribution. + */ + override fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean) { + if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment!!.isVisible) { + mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true) + if (isUserProfile) { + (activity as ProfileActivity).setScroll(false) + } + showMediaDetailPagerFragment() + } + mediaDetailPagerFragment!!.showImage(position, isWikipediaButtonDisplayed) + } + + override fun getMediaAtPosition(i: Int): Media? { + return contributionsListFragment!!.getMediaAtPosition(i) + } + + override fun getTotalMediaCount(): Int { + return contributionsListFragment!!.totalMediaCount + } + + override fun getContributionStateAt(position: Int): Int { + return contributionsListFragment!!.getContributionStateAt(position) + } + + fun backButtonClicked(): Boolean { + if (mediaDetailPagerFragment != null && mediaDetailPagerFragment!!.isVisible) { + if (store!!.getBoolean("displayNearbyCardView", true) && !isUserProfile) { + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + binding!!.cardViewNearby.visibility = View.GONE + } + removeFragment(mediaDetailPagerFragment!!) + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + if (isUserProfile) { + // Fragment is associated with ProfileActivity + // Enable ParentViewPager Scroll + (activity as ProfileActivity).setScroll(true) + } else { + fetchCampaigns() + } + if (activity is MainActivity) { + // Fragment is associated with MainActivity + (activity as BaseActivity).supportActionBar + ?.setDisplayHomeAsUpEnabled(false) + (activity as MainActivity).showTabs() + } + return true + } + return false + } + + + /** + * this function updates the number of contributions + */ + fun upDateUploadCount() { + context?.let { + WorkManager.getInstance(it) + .getWorkInfosForUniqueWorkLiveData(UploadWorker::class.java.simpleName).observe( + viewLifecycleOwner + ) { workInfos: List -> + if (workInfos.size > 0) { + setUploadCount() + } + } + } + } + + + /** + * Restarts the upload process for a contribution + * + * @param contribution + */ + fun restartUpload(contribution: Contribution) { + contribution.dateUploadStarted = Calendar.getInstance().time + if (contribution.state == Contribution.STATE_FAILED) { + if (contribution.errorInfo == null) { + contribution.chunkInfo = null + contribution.transferred = 0 + } + contributionsPresenter!!.checkDuplicateImageAndRestartContribution(contribution) + } else { + contribution.state = Contribution.STATE_QUEUED + contributionsPresenter!!.saveContribution(contribution) + Timber.d("Restarting for %s", contribution.toString()) + } + } + + /** + * Retry upload when it is failed + * + * @param contribution contribution to be retried + */ + fun retryUpload(contribution: Contribution) { + if (isInternetConnectionEstablished(context)) { + if (contribution.state == Contribution.STATE_PAUSED) { + restartUpload(contribution) + } else if (contribution.state == Contribution.STATE_FAILED) { + val retries = contribution.retries + // 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.retries = retries + 1 + Timber.d( + "Retried uploading %s %d times", contribution.media.filename, + retries + 1 + ) + restartUpload(contribution) + } else { + // TODO: Show the exact reason for failure + Toast.makeText( + context, + R.string.retry_limit_reached, Toast.LENGTH_SHORT + ).show() + } + } else { + Timber.d("Skipping re-upload for non-failed %s", contribution.toString()) + } + } else { + context?.let { showLongToast(it, R.string.this_function_needs_network_connection) } + } + } + + /** + * Reload media detail fragment once media is nominated + * + * @param index item position that has been nominated + */ + override fun refreshNominatedMedia(index: Int) { + if (mediaDetailPagerFragment != null && !contributionsListFragment!!.isVisible) { + removeFragment(mediaDetailPagerFragment!!) + mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true) + mediaDetailPagerFragment?.showImage(index) + showMediaDetailPagerFragment() + } + } + + /** + * When the device rotates, rotate the Nearby banner's compass arrow in tandem. + */ + override fun onSensorChanged(event: SensorEvent) { + val rotateDegree = Math.round(event.values[0]).toFloat() + binding!!.cardViewNearby.rotateCompass(rotateDegree, direction) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + // Nothing to do. + } + + companion object { + private const val CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag" + const val MEDIA_DETAIL_PAGER_FRAGMENT_TAG: String = "MediaDetailFragmentTag" + private const val MAX_RETRIES = 10 + + @JvmStatic + fun newInstance(): ContributionsFragment { + val fragment = ContributionsFragment() + fragment.retainInstance = true + return fragment + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt new file mode 100644 index 000000000..e5f721721 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt @@ -0,0 +1,77 @@ +package fr.free.nrw.commons.contributions + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +import fr.free.nrw.commons.media.MediaClient +import io.reactivex.disposables.CompositeDisposable + +/** + * Represents The View Adapter for the List of Contributions + */ +class ContributionsListAdapter internal constructor( + private val callback: Callback, + private val mediaClient: MediaClient, + private val mediaDataExtractor: MediaDataExtractor, + private val compositeDisposable: CompositeDisposable +) : PagedListAdapter(DIFF_CALLBACK) { + /** + * Initializes the view holder with contribution data + */ + override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) { + holder.init(position, getItem(position)) + holder.updateAttribution() + } + + fun getContributionForPosition(position: Int): Contribution? { + return getItem(position) + } + + /** + * Creates the new View Holder which will be used to display items(contributions) using the + * onBindViewHolder(viewHolder,position) + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ContributionViewHolder { + val viewHolder = ContributionViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.layout_contribution, parent, false), + callback, compositeDisposable, mediaClient, mediaDataExtractor + ) + return viewHolder + } + + interface Callback { + fun openMediaDetail(contribution: Int, isWikipediaPageExists: Boolean) + + fun addImageToWikipedia(contribution: Contribution?) + } + + companion object { + /** + * Uses DiffUtil to calculate the changes in the list + * It has methods that check ID and the content of the items to determine if its a new item + */ + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldContribution: Contribution, + newContribution: Contribution + ): Boolean { + return oldContribution.pageId == newContribution.pageId + } + + override fun areContentsTheSame( + oldContribution: Contribution, + newContribution: Contribution + ): Boolean { + return oldContribution == newContribution + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt new file mode 100644 index 000000000..0c8c822ad --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.contributions + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import fr.free.nrw.commons.BasePresenter + +/** + * The contract for Contributions list View & Presenter + */ +class ContributionsListContract { + interface View { + fun showWelcomeTip(numberOfUploads: Boolean) + + fun showProgress(shouldShow: Boolean) + + fun showNoContributionsUI(shouldShow: Boolean) + } + + interface UserActionListener : BasePresenter { + fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt new file mode 100644 index 000000000..6d0822604 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt @@ -0,0 +1,565 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.LinearLayout +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import androidx.core.os.BundleCompat +import androidx.paging.PagedList +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener +import androidx.recyclerview.widget.SimpleItemAnimator +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.contributions.WikipediaInstructionsDialogFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.FragmentContributionsListBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.di.NetworkingModule +import fr.free.nrw.commons.filepicker.FilePicker +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.utils.copyToClipboard +import fr.free.nrw.commons.utils.handleWebUrl +import fr.free.nrw.commons.wikidata.model.WikiSite +import javax.inject.Inject +import javax.inject.Named + + +/** + * Created by root on 01.06.2018. + */ +class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsListContract.View, + ContributionsListAdapter.Callback, WikipediaInstructionsDialogFragment.Callback { + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var mediaClient: MediaClient? = null + + @JvmField + @Inject + var mediaDataExtractor: MediaDataExtractor? = null + + @JvmField + @Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) + @Inject + var languageWikipediaSite: WikiSite? = null + + @JvmField + @Inject + var contributionsListPresenter: ContributionsListPresenter? = null + + @JvmField + @Inject + var sessionManager: SessionManager? = null + + private var binding: FragmentContributionsListBinding? = null + private var fabClose: Animation? = null + private var fabOpen: Animation? = null + private var rotateForward: Animation? = null + private var rotateBackward: Animation? = null + private var isFabOpen = false + + private lateinit var inAppCameraLocationPermissionLauncher: + ActivityResultLauncher> + + @VisibleForTesting + var rvContributionsList: RecyclerView? = null + + @VisibleForTesting + var adapter: ContributionsListAdapter? = null + + @VisibleForTesting + var callback: Callback? = null + + private val spanCountLandscape = 3 + private val spanCountPortrait = 1 + + private var contributionsSize = 0 + private var userName: String? = null + + private val galleryPickLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromGallery( + result!!, requireActivity(), callbacks!! + ) + } + } + + private val customSelectorLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromCustomSelector( + result!!, requireActivity(), callbacks!! + ) + } + } + + private val cameraPickLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromCamera( + result!!, requireActivity(), callbacks!! + ) + } + } + + @SuppressLint("NewApi") + override fun onCreate( + savedInstanceState: Bundle? + ) { + 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 + if (arguments != null) { + userName = requireArguments().getString(ProfileActivity.KEY_USERNAME) + } + + if (userName.isNullOrEmpty()) { + userName = sessionManager!!.userName + } + inAppCameraLocationPermissionLauncher = + registerForActivityResult(RequestMultiplePermissions()) { result -> + val areAllGranted = result.values.all { it } + + if (areAllGranted) { + controller?.locationPermissionCallback?.onLocationPermissionGranted() + } else { + activity?.let { currentActivity -> + if (currentActivity.shouldShowRequestPermissionRationale( + permission.ACCESS_FINE_LOCATION)) { + controller?.handleShowRationaleFlowCameraLocation( + currentActivity, + inAppCameraLocationPermissionLauncher, // Pass launcher + cameraPickLauncherForResult + ) + } else { + controller?.locationPermissionCallback?.onLocationPermissionDenied( + currentActivity.getString( + R.string.in_app_camera_location_permission_denied) + ) + } + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentContributionsListBinding.inflate( + inflater, container, false + ) + rvContributionsList = binding!!.contributionsList + + contributionsListPresenter!!.onAttachView(this) + binding!!.fabCustomGallery.setOnClickListener { v: View? -> launchCustomSelector() } + binding!!.fabCustomGallery.setOnLongClickListener { view: View? -> + showShortToast(context, R.string.custom_selector_title) + true + } + + if (sessionManager!!.userName == userName) { + binding!!.tvContributionsOfUser.visibility = View.GONE + binding!!.fabLayout.visibility = View.VISIBLE + } else { + binding!!.tvContributionsOfUser.visibility = View.VISIBLE + binding!!.tvContributionsOfUser.text = + getString(R.string.contributions_of_user, userName) + binding!!.fabLayout.visibility = View.GONE + } + + initAdapter() + + // pull down to refresh only enabled for self user. + if (sessionManager!!.userName == userName) { + binding!!.swipeRefreshLayout.setOnRefreshListener { + contributionsListPresenter!!.refreshList( + binding!!.swipeRefreshLayout + ) + } + } else { + binding!!.swipeRefreshLayout.isEnabled = false + } + + return binding!!.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (parentFragment != null && parentFragment is ContributionsFragment) { + callback = (parentFragment as ContributionsFragment) + } + } + + override fun onDetach() { + super.onDetach() + callback = null //To avoid possible memory leak + } + + private fun initAdapter() { + adapter = ContributionsListAdapter(this, + mediaClient!!, + mediaDataExtractor!!, + compositeDisposable) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRecyclerView() + initializeAnimations() + setListeners() + } + + private fun initRecyclerView() { + val layoutManager = GridLayoutManager( + context, + getSpanCount(resources.configuration.orientation) + ) + rvContributionsList!!.layoutManager = layoutManager + + //Setting flicker animation of recycler view to false. + val animator = rvContributionsList!!.itemAnimator + if (animator is SimpleItemAnimator) { + animator.supportsChangeAnimations = false + } + + contributionsListPresenter!!.setup( + userName, + sessionManager!!.userName == userName + ) + contributionsListPresenter!!.contributionList?.observe( + viewLifecycleOwner + ) { list: PagedList? -> + if (list != null) { + contributionsSize = list.size + } + adapter!!.submitList(list) + if (callback != null) { + callback!!.notifyDataSetChanged() + } + } + rvContributionsList!!.adapter = adapter + adapter!!.registerAdapterDataObserver(object : AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + contributionsSize = adapter!!.itemCount + if (callback != null) { + callback!!.notifyDataSetChanged() + } + if (itemCount > 0 && positionStart == 0) { + if (adapter!!.getContributionForPosition(positionStart) != null) { + rvContributionsList!! + .scrollToPosition(0) //Newly upload items are always added to the top + } + } + } + + /** + * Called whenever items in the list have changed + * Calls viewPagerNotifyDataSetChanged() that will notify the viewpager + */ + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + super.onItemRangeChanged(positionStart, itemCount) + if (callback != null) { + callback!!.viewPagerNotifyDataSetChanged() + } + } + }) + + //Fab close on touch outside (Scrolling or taping on item triggers this action). + rvContributionsList!!.addOnItemTouchListener(object : OnItemTouchListener { + /** + * Silently observe and/or take over touch events sent to the RecyclerView before + * they are handled by either the RecyclerView itself or its child views. + */ + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + if (e.action == MotionEvent.ACTION_DOWN) { + if (isFabOpen) { + animateFAB(true) + } + } + return false + } + + /** + * Process a touch event as part of a gesture that was claimed by returning true + * from a previous call to [.onInterceptTouchEvent]. + * + * @param rv + * @param e MotionEvent describing the touch event. All coordinates are in the + * RecyclerView's coordinate system. + */ + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + //required abstract method DO NOT DELETE + } + + /** + * Called when a child of RecyclerView does not want RecyclerView and its ancestors + * to intercept touch events with [ViewGroup.onInterceptTouchEvent]. + * + * @param disallowIntercept True if the child does not want the parent to intercept + * touch events. + */ + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + //required abstract method DO NOT DELETE + } + }) + } + + private fun getSpanCount(orientation: Int): Int { + return if (orientation == Configuration.ORIENTATION_LANDSCAPE) + spanCountLandscape + else + spanCountPortrait + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // check orientation + binding!!.fabLayout.orientation = + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) + LinearLayout.HORIZONTAL + else + LinearLayout.VERTICAL + rvContributionsList + ?.setLayoutManager( + GridLayoutManager(context, getSpanCount(newConfig.orientation)) + ) + } + + private fun initializeAnimations() { + fabOpen = AnimationUtils.loadAnimation(activity, R.anim.fab_open) + fabClose = AnimationUtils.loadAnimation(activity, R.anim.fab_close) + rotateForward = AnimationUtils.loadAnimation(activity, R.anim.rotate_forward) + rotateBackward = AnimationUtils.loadAnimation(activity, R.anim.rotate_backward) + } + + private fun setListeners() { + binding!!.fabPlus.setOnClickListener { view: View? -> animateFAB(isFabOpen) } + binding!!.fabCamera.setOnClickListener { view: View? -> + controller!!.initiateCameraPick( + requireActivity(), + inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult + ) + animateFAB(isFabOpen) + } + binding!!.fabCamera.setOnLongClickListener { view: View? -> + showShortToast( + context, + R.string.add_contribution_from_camera + ) + true + } + binding!!.fabGallery.setOnClickListener { view: View? -> + controller!!.initiateGalleryPick(requireActivity(), galleryPickLauncherForResult, true) + animateFAB(isFabOpen) + } + binding!!.fabGallery.setOnLongClickListener { view: View? -> + showShortToast(context, R.string.menu_from_gallery) + true + } + } + + /** + * Launch Custom Selector. + */ + private fun launchCustomSelector() { + controller!!.initiateCustomGalleryPickWithPermission( + requireActivity(), + customSelectorLauncherForResult + ) + animateFAB(isFabOpen) + } + + fun scrollToTop() { + rvContributionsList!!.smoothScrollToPosition(0) + } + + private fun animateFAB(isFabOpen: Boolean) { + this.isFabOpen = !isFabOpen + if (binding!!.fabPlus.isShown) { + if (isFabOpen) { + binding!!.fabPlus.startAnimation(rotateBackward) + binding!!.fabCamera.startAnimation(fabClose) + binding!!.fabGallery.startAnimation(fabClose) + binding!!.fabCustomGallery.startAnimation(fabClose) + binding!!.fabCamera.hide() + binding!!.fabGallery.hide() + binding!!.fabCustomGallery.hide() + } else { + binding!!.fabPlus.startAnimation(rotateForward) + binding!!.fabCamera.startAnimation(fabOpen) + binding!!.fabGallery.startAnimation(fabOpen) + binding!!.fabCustomGallery.startAnimation(fabOpen) + binding!!.fabCamera.show() + binding!!.fabGallery.show() + binding!!.fabCustomGallery.show() + } + this.isFabOpen = !isFabOpen + } + } + + /** + * Shows welcome message if user has no contributions yet i.e. new user. + */ + override fun showWelcomeTip(numberOfUploads: Boolean) { + binding!!.noContributionsYet.visibility = + if (numberOfUploads) View.VISIBLE else View.GONE + } + + /** + * Responsible to set progress bar invisible and visible + * + * @param shouldShow True when contributions list should be hidden. + */ + override fun showProgress(shouldShow: Boolean) { + binding!!.loadingContributionsProgressBar.visibility = + if (shouldShow) View.VISIBLE else View.GONE + } + + override fun showNoContributionsUI(shouldShow: Boolean) { + binding!!.noContributionsYet.visibility = + if (shouldShow) View.VISIBLE else View.GONE + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val layoutManager = rvContributionsList?.layoutManager as GridLayoutManager? + outState.putParcelable(RV_STATE, layoutManager!!.onSaveInstanceState()) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + if (null != savedInstanceState) { + val savedRecyclerLayoutState = + BundleCompat.getParcelable(savedInstanceState, RV_STATE, Parcelable::class.java) + rvContributionsList!!.layoutManager!!.onRestoreInstanceState(savedRecyclerLayoutState) + } + } + + override fun openMediaDetail(contribution: Int, isWikipediaPageExists: Boolean) { + if (null != callback) { //Just being safe, ideally they won't be called when detached + callback!!.showDetail(contribution, isWikipediaPageExists) + } + } + + /** + * Handle callback for wikipedia icon clicked + * + * @param contribution + */ + override fun addImageToWikipedia(contribution: Contribution?) { + showAlertDialog( + requireActivity(), + getString(R.string.add_picture_to_wikipedia_article_title), + getString(R.string.add_picture_to_wikipedia_article_desc), + { + if (contribution != null) { + showAddImageToWikipediaInstructions(contribution) + } + }, {}) + } + + /** + * Display confirmation dialog with instructions when the user tries to add image to wikipedia + * + * @param contribution + */ + private fun showAddImageToWikipediaInstructions(contribution: Contribution) { + val fragmentManager = this.parentFragmentManager + val fragment = newInstance(contribution) + fragment.callback = + WikipediaInstructionsDialogFragment.Callback { + contribution: Contribution?, + copyWikicode: Boolean -> + onConfirmClicked( + contribution, + copyWikicode + ) + } + fragment.show(fragmentManager, "WikimediaFragment") + } + + + fun getMediaAtPosition(i: Int): Media? { + if (adapter!!.getContributionForPosition(i) != null) { + return adapter!!.getContributionForPosition(i)!!.media + } + return null + } + + val totalMediaCount: Int + get() = contributionsSize + + /** + * Open the editor for the language Wikipedia + * + * @param contribution + */ + override fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) { + if (copyWikicode) { + requireContext().copyToClipboard("wikicode", contribution!!.media.wikiCode) + } + + val url = + languageWikipediaSite!!.mobileUrl() + "/wiki/" + (contribution!!.wikidataPlace + ?.getWikipediaPageTitle()) + handleWebUrl(requireContext(), url.toUri()) + } + + fun getContributionStateAt(position: Int): Int { + return adapter!!.getContributionForPosition(position)!!.state + } + + interface Callback { + fun notifyDataSetChanged() + + fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean) + + // Notify the viewpager that number of items have changed. + fun viewPagerNotifyDataSetChanged() + } + + companion object { + private const val RV_STATE = "rv_scroll_state" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt new file mode 100644 index 000000000..1421c1757 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt @@ -0,0 +1,91 @@ +package fr.free.nrw.commons.contributions + +import androidx.lifecycle.LiveData +import androidx.paging.DataSource +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import fr.free.nrw.commons.di.CommonsApplicationModule +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import javax.inject.Inject +import javax.inject.Named + +/** + * The presenter class for Contributions + */ +class ContributionsListPresenter @Inject internal constructor( + private val contributionBoundaryCallback: ContributionBoundaryCallback, + private val contributionsRemoteDataSource: ContributionsRemoteDataSource, + private val repository: ContributionsRepository, + @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler +) : ContributionsListContract.UserActionListener { + private val compositeDisposable = CompositeDisposable() + + var contributionList: LiveData>? = null + + override fun onAttachView(view: ContributionsListContract.View) { + } + + /** + * Setup the paged list. This method sets the configuration for paged list and ties it up with + * the live data object. This method can be tweaked to update the lazy loading behavior of the + * contributions list + */ + fun setup(userName: String?, isSelf: Boolean) { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory: DataSource.Factory + val shouldSetBoundaryCallback: Boolean + if (!isSelf) { + //We don't want to persist contributions for other user's, therefore + // creating a new DataSource for them + contributionsRemoteDataSource.userName = userName + factory = object : DataSource.Factory() { + override fun create(): DataSource { + return contributionsRemoteDataSource + } + } + shouldSetBoundaryCallback = false + } else { + contributionBoundaryCallback.userName = userName + shouldSetBoundaryCallback = true + factory = repository.fetchContributionsWithStates( + listOf(Contribution.STATE_COMPLETED) + ) + } + + val livePagedListBuilder: LivePagedListBuilder = LivePagedListBuilder( + factory, + pagedListConfig + ) + if (shouldSetBoundaryCallback) { + livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback) + } + + contributionList = livePagedListBuilder.build() + } + + override fun onDetachView() { + compositeDisposable.clear() + contributionsRemoteDataSource.dispose() + contributionBoundaryCallback.dispose() + } + + /** + * It is used to refresh list. + * + * @param swipeRefreshLayout used to stop refresh animation when + * refresh finishes. + */ + override fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?) { + contributionBoundaryCallback.refreshList { + if (swipeRefreshLayout != null) { + swipeRefreshLayout.isRefreshing = false + } + Unit + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt new file mode 100644 index 000000000..a35cc15db --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.DataSource +import fr.free.nrw.commons.kvstore.JsonKvStore +import io.reactivex.Completable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Named + +/** + * The LocalDataSource class for Contributions + */ +class ContributionsLocalDataSource @Inject constructor( + @param:Named("default_preferences") private val defaultKVStore: JsonKvStore, + private val contributionDao: ContributionDao +) { + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getString(key: String): String? { + return defaultKVStore.getString(key) + } + + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getLong(key: String): Long { + return defaultKVStore.getLong(key) + } + + /** + * Get contribution object from cursor + * + * @param uri + * @return + */ + fun getContributionWithFileName(uri: String): Contribution { + val contributionWithUri = contributionDao.getContributionWithTitle(uri) + if (contributionWithUri.isNotEmpty()) { + return contributionWithUri[0] + } + throw IllegalArgumentException("Contribution not found for URI: $uri") + } + + /** + * Remove a contribution from the contributions table + * + * @param contribution + * @return + */ + fun deleteContribution(contribution: Contribution): Completable { + return contributionDao.delete(contribution) + } + + /** + * Deletes contributions with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsWithStates(states: List): Completable { + return contributionDao.deleteContributionsWithStates(states) + } + + fun getContributions(): DataSource.Factory { + return contributionDao.fetchContributions() + } + + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + fun getContributionsWithStates(states: List): DataSource.Factory { + return contributionDao.getContributions(states) + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + fun getContributionsWithStatesSortedByDateUploadStarted( + states: List + ): DataSource.Factory { + return contributionDao.getContributionsSortedByDateUploadStarted(states) + } + + fun saveContributions(contributions: List): Single> { + val contributionList: MutableList = ArrayList() + for (contribution in contributions) { + val oldContribution = contributionDao.getContribution( + contribution.pageId + ) + if (oldContribution != null) { + contribution.wikidataPlace = oldContribution.wikidataPlace + } + contributionList.add(contribution) + } + return contributionDao.save(contributionList) + } + + fun saveContributions(contribution: Contribution): Completable { + return contributionDao.save(contribution) + } + + fun set(key: String, value: Long) { + defaultKVStore.putLong(key, value) + } + + fun updateContribution(contribution: Contribution): Completable { + return contributionDao.update(contribution) + } + + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return contributionDao.updateContributionsWithStates(states, newState) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt new file mode 100644 index 000000000..0e27dbade --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.contributions + +import dagger.Binds +import dagger.Module + +/** + * The Dagger Module for contributions-related presenters and other dependencies + */ +@Module +abstract class ContributionsModule { + + @Binds + abstract fun bindsContributionsPresenter( + presenter: ContributionsPresenter? + ): ContributionsContract.UserActionListener? +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt new file mode 100644 index 000000000..2a36101b9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt @@ -0,0 +1,90 @@ +package fr.free.nrw.commons.contributions + +import androidx.work.ExistingWorkPolicy +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.di.CommonsApplicationModule +import fr.free.nrw.commons.repository.UploadRepository +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ImageUtils +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named + +/** + * The presenter class for Contributions + */ +class ContributionsPresenter @Inject internal constructor( + private val contributionsRepository: ContributionsRepository, + private val uploadRepository: UploadRepository, + @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler +) : ContributionsContract.UserActionListener { + private var compositeDisposable: CompositeDisposable? = null + private var view: ContributionsContract.View? = null + + @JvmField + @Inject + var mediaDataExtractor: MediaDataExtractor? = null + + override fun onAttachView(view: ContributionsContract.View) { + this.view = view + compositeDisposable = CompositeDisposable() + } + + override fun onDetachView() { + this.view = null + compositeDisposable!!.clear() + } + + override fun getContributionsWithTitle(title: String): Contribution { + return contributionsRepository.getContributionWithFileName(title) + } + + /** + * Checks if a contribution is a duplicate and restarts the contribution process if it is not. + * + * @param contribution The contribution to check and potentially restart. + */ + fun checkDuplicateImageAndRestartContribution(contribution: Contribution) { + compositeDisposable!!.add( + uploadRepository + .checkDuplicateImage( + contribution.contentUri, + contribution.localUri) + .subscribeOn(ioThreadScheduler) + .subscribe { imageCheckResult: Int -> + if (imageCheckResult == ImageUtils.IMAGE_OK) { + contribution.state = Contribution.STATE_QUEUED + saveContribution(contribution) + } else { + Timber.e("Contribution already exists") + compositeDisposable!!.add( + contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + }) + } + + /** + * Update the contribution's state in the databse, upon completion, trigger the workmanager to + * process this contribution + * + * @param contribution + */ + fun saveContribution(contribution: Contribution) { + compositeDisposable!!.add(contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe { + view!!.getContext()?.applicationContext?.let { + makeOneTimeWorkRequest( + it, ExistingWorkPolicy.KEEP + ) + } + }) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt new file mode 100644 index 000000000..67e8f50b5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt @@ -0,0 +1,28 @@ +package fr.free.nrw.commons.contributions + +import dagger.Module +import dagger.Provides +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.wikidata.model.WikiSite +import javax.inject.Named + +/** + * The Dagger Module for contributions-related providers + */ +@Module +class ContributionsProvidesModule { + + @Provides + fun providesApplicationKvStore( + @Named("default_preferences") kvStore: JsonKvStore + ): JsonKvStore { + return kvStore + } + + @Provides + fun providesLanguageWikipediaSite( + @Named("language-wikipedia-wikisite") languageWikipediaSite: WikiSite + ): WikiSite { + return languageWikipediaSite + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt new file mode 100644 index 000000000..e8ff01b3e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt @@ -0,0 +1,72 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.ItemKeyedDataSource +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD +import fr.free.nrw.commons.media.MediaClient +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named + +/** + * Data-Source which acts as mediator for contributions-data from the API + */ +class ContributionsRemoteDataSource + @Inject + constructor( + private val mediaClient: MediaClient, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, + ) : ItemKeyedDataSource() { + private val compositeDisposable: CompositeDisposable = CompositeDisposable() + var userName: String? = null + + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback, + ) { + fetchContributions(callback) + } + + override fun loadAfter( + params: LoadParams, + callback: LoadCallback, + ) { + fetchContributions(callback) + } + + override fun loadBefore( + params: LoadParams, + callback: LoadCallback, + ) { + } + + override fun getKey(item: Contribution): Int = item.pageId.hashCode() + + /** + * Fetches contributions using the MediaWiki API + */ + private fun fetchContributions(callback: LoadCallback) { + compositeDisposable.add( + mediaClient + .getMediaListForUser(userName!!) + .map { mediaList -> + mediaList.map { + Contribution(media = it, state = Contribution.STATE_COMPLETED) + } + }.subscribeOn(ioThreadScheduler) + .subscribe({ + callback.onResult(it) + }) { error: Throwable -> + Timber.e( + "Failed to fetch contributions: %s", + error.message, + ) + }, + ) + } + + fun dispose() { + compositeDisposable.dispose() + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt new file mode 100644 index 000000000..462dbfc7d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt @@ -0,0 +1,102 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.DataSource +import io.reactivex.Completable +import io.reactivex.Single +import javax.inject.Inject + +/** + * The repository class for contributions + */ +class ContributionsRepository @Inject constructor(private val localDataSource: ContributionsLocalDataSource) { + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getString(key: String): String? { + return localDataSource.getString(key) + } + + /** + * Deletes a failed upload from DB + * + * @param contribution + * @return + */ + fun deleteContributionFromDB(contribution: Contribution): Completable { + return localDataSource.deleteContribution(contribution) + } + + /** + * Deletes contributions from the database with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsFromDBWithStates(states: List): Completable { + return localDataSource.deleteContributionsWithStates(states) + } + + /** + * Get contribution object with title + * + * @param fileName + * @return + */ + fun getContributionWithFileName(fileName: String): Contribution { + return localDataSource.getContributionWithFileName(fileName) + } + + fun fetchContributions(): DataSource.Factory { + return localDataSource.getContributions() + } + + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + fun fetchContributionsWithStates(states: List): DataSource.Factory { + return localDataSource.getContributionsWithStates(states) + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + fun fetchContributionsWithStatesSortedByDateUploadStarted( + states: List + ): DataSource.Factory { + return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states) + } + + fun save(contributions: List): Single> { + return localDataSource.saveContributions(contributions) + } + + fun save(contributions: Contribution): Completable { + return localDataSource.saveContributions(contributions) + } + + operator fun set(key: String, value: Long) { + localDataSource.set(key, value) + } + + fun updateContribution(contribution: Contribution): Completable { + return localDataSource.updateContribution(contribution) + } + + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return localDataSource.updateContributionsWithStates(states, newState) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt new file mode 100644 index 000000000..d481017b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -0,0 +1,562 @@ +package fr.free.nrw.commons.contributions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.work.ExistingWorkPolicy +import com.google.android.material.bottomnavigation.BottomNavigationView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.contributions.ContributionsFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.MainBinding +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment +import fr.free.nrw.commons.navtab.NavTab +import fr.free.nrw.commons.navtab.NavTabLayout +import fr.free.nrw.commons.navtab.NavTabLoggedOut +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +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.startWelcome +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Completable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + + +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + @JvmField + @Inject + var sessionManager: SessionManager? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var contributionDao: ContributionDao? = null + + private var contributionsFragment: ContributionsFragment? = null + private var nearbyParentFragment: NearbyParentFragment? = null + private var exploreFragment: ExploreFragment? = null + private var bookmarkFragment: BookmarkFragment? = null + @JvmField + var activeFragment: ActiveFragment? = null + private val mediaDetailPagerFragment: MediaDetailPagerFragment? = null + var navListener: BottomNavigationView.OnNavigationItemSelectedListener? = null + private set + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var quizChecker: QuizChecker? = null + + @JvmField + @Inject + @Named("default_preferences") + var applicationKvStore: JsonKvStore? = null + + @JvmField + @Inject + var viewUtilWrapper: ViewUtilWrapper? = null + + var menu: Menu? = null + + @JvmField + var binding: MainBinding? = null + + var tabLayout: NavTabLayout? = null + + + override fun onSupportNavigateUp(): Boolean { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + if (!contributionsFragment!!.backButtonClicked()) { + return false + } + } else { + onBackPressed() + showTabs() + } + return true + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = MainBinding.inflate(layoutInflater) + applyEdgeToEdgeAllInsets(binding!!.root) + setContentView(binding!!.root) + setSupportActionBar(binding!!.toolbarBinding.toolbar) + tabLayout = binding!!.fragmentMainNavTabLayout + loadLocale() + + binding!!.toolbarBinding.toolbar.setNavigationOnClickListener { view: View? -> + onSupportNavigateUp() + } + /* +"first_edit_depict" is a key for getting information about opening the depiction editor +screen for the first time after opening the app. + +Getting true by the key means the depiction editor screen is opened for the first time +after opening the app. +Getting false by the key means the depiction editor screen is not opened for the first time +after opening the app. + */ + applicationKvStore!!.putBoolean("first_edit_depict", true) + if (applicationKvStore!!.getBoolean("login_skipped") == true) { + title = 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 + if (applicationKvStore!!.getBoolean("last_opened_nearby")) { + title = getString(R.string.nearby_fragment) + showNearby() + loadFragment(NearbyParentFragment.newInstance(), false) + } else { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } + } + setUpPager() + + checkAndResumeStuckUploads() + } + } + + fun setSelectedItemId(id: Int) { + binding!!.fragmentMainNavTabLayout.selectedItemId = id + } + + private fun setUpPager() { + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( + BottomNavigationView.OnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + // set last_opened_nearby true if item is nearby screen else set false + applicationKvStore!!.putBoolean( + "last_opened_nearby", + item.title == getString(R.string.nearby_fragment) + ) + val fragment = NavTab.of(item.order).newInstance() + loadFragment(fragment, true) + }.also { navListener = it }) + } + + private fun setUpLoggedOutPager() { + loadFragment(ExploreFragment.newInstance(), false) + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + val fragment = + NavTabLoggedOut.of(item.order).newInstance() + loadFragment(fragment, true) + } + } + + private fun loadFragment(fragment: Fragment?, showBottom: Boolean): Boolean { + //showBottom so that we do not show the bottom tray again when constructing + //from the saved instance state. + + freeUpFragments(); + + if (fragment is ContributionsFragment) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + // scroll to top if already on the Contributions tab + contributionsFragment!!.scrollToTop() + return true + } + contributionsFragment = fragment + activeFragment = ActiveFragment.CONTRIBUTIONS + } else if (fragment is NearbyParentFragment) { + if (activeFragment == ActiveFragment.NEARBY) { // Do nothing if same tab + return true + } + nearbyParentFragment = fragment + activeFragment = ActiveFragment.NEARBY + } else if (fragment is ExploreFragment) { + if (activeFragment == ActiveFragment.EXPLORE) { // Do nothing if same tab + return true + } + exploreFragment = fragment + activeFragment = ActiveFragment.EXPLORE + } else if (fragment is BookmarkFragment) { + if (activeFragment == ActiveFragment.BOOKMARK) { // Do nothing if same tab + return true + } + bookmarkFragment = fragment + activeFragment = ActiveFragment.BOOKMARK + } else if (fragment == null && showBottom) { + if (applicationKvStore!!.getBoolean("login_skipped") + == true + ) { // If logged out, more sheet is different + val bottomSheet = MoreBottomSheetLoggedOutFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheetLoggedOut" + ) + } else { + val bottomSheet = MoreBottomSheetFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheet" + ) + } + } + + if (fragment != null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragmentContainer, fragment) + .commit() + return true + } + return false + } + + /** + * loadFragment() overload that supports passing extras to fragments + */ + private fun loadFragment(fragment: Fragment?, showBottom: Boolean, args: Bundle?): Boolean { + if (fragment != null && args != null) { + fragment.arguments = args + } + + return loadFragment(fragment, showBottom) + } + + /** + * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding + * references to cleared fragments. This function frees up all fragment references. + * + * + * Called in loadFragment() before doing the actual loading. + */ + fun freeUpFragments() { + // free all fragments except contributionsFragment because several tests depend on it. + // hence, contributionsFragment is probably still a leak + nearbyParentFragment = null + exploreFragment = null + bookmarkFragment = null + } + + + fun hideTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.GONE + } + + fun showTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.VISIBLE + } + + /** + * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions + * (NUMBER)" + * + * @param uploadCount + */ + fun setNumOfUploads(uploadCount: Int) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + title = + resources.getString(R.string.contributions_fragment) + " " + (if (uploadCount != 0) + resources + .getQuantityString( + R.plurals.contributions_subtitle, + uploadCount, uploadCount + ) + else + getString(R.string.contributions_subtitle_zero)) + } + } + + /** + * 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 fun checkAndResumeStuckUploads() { + val stuckUploads = contributionDao!!.getContribution( + listOf(Contribution.STATE_IN_PROGRESS) + ) + .subscribeOn(Schedulers.io()) + .blockingGet() + Timber.d("Resuming %d uploads...", stuckUploads.size) + if (!stuckUploads.isEmpty()) { + for (contribution in stuckUploads) { + contribution.state = Contribution.STATE_QUEUED + contribution.dateUploadStarted = Calendar.getInstance().time + Completable.fromAction { contributionDao!!.saveSynchronous(contribution) } + .subscribeOn(Schedulers.io()) + .subscribe() + } + makeOneTimeWorkRequest( + this, ExistingWorkPolicy.APPEND_OR_REPLACE + ) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + //quizChecker.initQuizCheck(this); + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("viewPagerCurrentItem", binding!!.pager.currentItem) + outState.putString("activeFragment", activeFragment!!.name) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val activeFragmentName = savedInstanceState.getString("activeFragment") + if (activeFragmentName != null) { + restoreActiveFragment(activeFragmentName) + } + } + + private fun restoreActiveFragment(fragmentName: String) { + if (fragmentName == ActiveFragment.CONTRIBUTIONS.name) { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } else if (fragmentName == ActiveFragment.NEARBY.name) { + title = getString(R.string.nearby_fragment) + loadFragment(NearbyParentFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.EXPLORE.name) { + title = getString(R.string.navigation_item_explore) + loadFragment(ExploreFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.BOOKMARK.name) { + title = getString(R.string.bookmarks) + loadFragment(BookmarkFragment.newInstance(), false) + } + } + + override fun onBackPressed() { + when (activeFragment) { + ActiveFragment.CONTRIBUTIONS -> { + // Means that contribution fragment is visible + if (contributionsFragment?.backButtonClicked() != true) { //If this one does not want to handle + // the back press, let the activity do so + super.onBackPressed() + } + } + ActiveFragment.NEARBY -> { + // Means that nearby fragment is visible + if (nearbyParentFragment?.backButtonClicked() != true) { + nearbyParentFragment?.let { + supportFragmentManager.beginTransaction().remove(it).commit() + } + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } + ActiveFragment.EXPLORE -> { + // Explore Fragment is visible + if (exploreFragment?.onBackPressed() != true) { + if (applicationKvStore?.getBoolean("login_skipped") == true) { + super.onBackPressed() + } else { + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } + } + ActiveFragment.BOOKMARK -> { + // Means that bookmark fragment is visible + bookmarkFragment?.onBackPressed() + } + else -> { + super.onBackPressed() + } + } + } + + override fun onBackStackChanged() { + //initBackButton(); + } + + /** + * Retry all failed uploads as soon as the user returns to the app + */ + @SuppressLint("CheckResult") + private fun retryAllFailedUploads() { + contributionDao + ?.getContribution(listOf(Contribution.STATE_FAILED)) + ?.subscribeOn(Schedulers.io()) + ?.subscribe { failedUploads -> + failedUploads.forEach { contribution -> + contributionsFragment?.retryUpload(contribution) + } + } + } + + /** + * Handles item selection in the options menu. This method is called when a user interacts with + * the options menu in the Top Bar. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.upload_tab -> { + startActivity(Intent(this, UploadProgressActivity::class.java)) + return true + } + + R.id.notifications -> { + // Starts notification activity on click to notification icon + startYourself(this, "unread") + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun centerMapToPlace(place: Place?) { + setSelectedItemId(NavTab.NEARBY.code()) + nearbyParentFragment!!.setNearbyParentFragmentInstanceReadyCallback( + object : NearbyParentFragmentInstanceReadyCallback { + override fun onReady() { + nearbyParentFragment!!.centerMapToPlace(place) + } + }) + } + + /** + * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks + * the 'Show in Explore' option in the 3-dots menu in Nearby. + * + * @param zoom current zoom of Nearby map + * @param latitude current latitude of Nearby map + * @param longitude current longitude of Nearby map + */ + fun loadExploreMapFromNearby(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(ExploreFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.EXPLORE.code()) + } + + /** + * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks + * the 'Show in Nearby' option in the 3-dots menu in Explore. + * + * @param zoom current zoom of Explore map + * @param latitude current latitude of Explore map + * @param longitude current longitude of Explore map + */ + fun loadNearbyMapFromExplore(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(NearbyParentFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.NEARBY.code()) + } + + override fun onResume() { + super.onResume() + + if ((applicationKvStore!!.getBoolean("firstrun", true)) && + (!applicationKvStore!!.getBoolean("login_skipped")) + ) { + defaultKvStore.putBoolean("inAppCameraFirstRun", true) + startWelcome() + } + + retryAllFailedUploads() + } + + override fun onDestroy() { + quizChecker!!.cleanup() + locationManager!!.unregisterLocationManager() + // Remove ourself from hashmap to prevent memory leaks + locationManager = null + super.onDestroy() + } + + /** + * Public method to show nearby from the reference of this. + */ + fun showNearby() { + binding!!.fragmentMainNavTabLayout.selectedItemId = NavTab.NEARBY.code() + } + + enum class ActiveFragment { + CONTRIBUTIONS, + NEARBY, + EXPLORE, + BOOKMARK, + MORE + } + + /** + * Load default language in onCreate from SharedPreferences + */ + private fun loadLocale() { + val preferences = getSharedPreferences( + "Settings", + MODE_PRIVATE + ) + val language = preferences.getString("language", "")!! + val settingsFragment = SettingsFragment() + settingsFragment.setLocale(this, language) + } + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * + * @param context A Context of the application package implementing this class. + */ + fun startYourself(context: Context) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt new file mode 100644 index 000000000..8e899fcba --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -0,0 +1,113 @@ +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.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +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.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.R +import timber.log.Timber + +class SetWallpaperWorker(context: Context, params: WorkerParameters) : + Worker(context, params) { + override fun doWork(): Result { + val context = applicationContext + createNotificationChannel(context) + showProgressNotification(context) + + val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() + + val imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(imageUrl)) + .build() + + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + public override fun onNewResultImpl(bitmap: Bitmap?) { + if (dataSource.isFinished && bitmap != null) { + Timber.d("Bitmap loaded from url %s", imageUrl.toString()) + setWallpaper(context, Bitmap.createBitmap(bitmap)) + dataSource.close() + } + } + + override fun onFailureImpl(dataSource: DataSource?>) { + Timber.d("Error getting bitmap from image url %s", imageUrl.toString()) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") + dataSource.close() + } + }, CallerThreadExecutor.getInstance()) + + return Result.success() + } + + private fun setWallpaper(context: Context, bitmap: Bitmap) { + val wallpaperManager = WallpaperManager.getInstance(context) + + try { + wallpaperManager.setBitmap(bitmap) + showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.") + } catch (e: Exception) { + Timber.e(e, "Error setting wallpaper") + showNotification(context, "Setting Wallpaper Failed", " " + e.localizedMessage) + } + } + + private fun showProgressNotification(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = 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 fun showNotification(context: Context, title: String, content: String) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = 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 fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Wallpaper Setting" + val description = "Notifications for wallpaper setting progress" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + companion object { + private const val NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt new file mode 100644 index 000000000..dd6ae661a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class UnswipableViewPager : ViewPager { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt new file mode 100644 index 000000000..f16a48b4c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -0,0 +1,62 @@ +package fr.free.nrw.commons.contributions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import fr.free.nrw.commons.databinding.DialogAddToWikipediaInstructionsBinding + +/** + * Dialog fragment for displaying instructions for editing wikipedia + */ +class WikipediaInstructionsDialogFragment : DialogFragment() { + var callback: Callback? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = DialogAddToWikipediaInstructionsBinding + .inflate(inflater, container, false) + .apply { + val contribution: Contribution? = requireArguments().getParcelable(ARG_CONTRIBUTION) + tvWikicode.setText(contribution?.media?.wikiCode) + instructionsCancel.setOnClickListener { dismiss() } + instructionsConfirm.setOnClickListener { + callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) + } + }.root + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + dialog!!.window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN, + ) + } + + /** + * Callback for handling confirm button clicked + */ + fun interface Callback { + fun onConfirmClicked( + contribution: Contribution?, + copyWikicode: Boolean, + ) + } + + companion object { + const val ARG_CONTRIBUTION = "contribution" + + @JvmStatic + fun newInstance(contribution: Contribution) = + WikipediaInstructionsDialogFragment().apply { + arguments = bundleOf(ARG_CONTRIBUTION to contribution) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt new file mode 100644 index 000000000..1bad2e2a5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt @@ -0,0 +1,189 @@ +package fr.free.nrw.commons.coordinates + + +import android.content.Context +import android.content.Intent +import android.net.Uri +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.notification.NotificationHelper +import fr.free.nrw.commons.notification.NotificationHelper.Companion.NOTIFICATION_EDIT_COORDINATES +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.util.Objects +import javax.inject.Inject +import javax.inject.Named +import org.apache.commons.lang3.StringUtils +import timber.log.Timber + + +/** + * Helper class for edit and update given coordinates and showing notification about new coordinates + * upgradation + */ +class CoordinateEditHelper @Inject constructor( + private val notificationHelper: NotificationHelper, + @Named("commons-page-edit") private val pageEditClient: PageEditClient, + private val viewUtil: ViewUtilWrapper +) { + + /** + * Public interface to edit coordinates + * @param context to be added + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @return Single + */ + fun makeCoordinatesEdit( + context: Context, + media: Media, + latitude: String, + longitude: String, + accuracy: String + ): Single? { + viewUtil.showShortToast( + context, + context.getString(R.string.coordinates_edit_helper_make_edit_toast) + ) + return addCoordinates(media, latitude, longitude, accuracy) + ?.flatMapSingle { result -> + Single.just(showCoordinatesEditNotification(context, media, latitude, longitude, accuracy, result)) + } + ?.firstOrError() + } + + /** + * Replaces new coordinates + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @return Observable + */ + private fun addCoordinates( + media: Media, + latitude: String, + longitude: String, + accuracy: String + ): Observable? { + Timber.d("thread is coordinates adding %s", Thread.currentThread().getName()) + val summary = "Adding Coordinates" + + val buffer = StringBuilder() + + val wikiText = media.filename?.let { + pageEditClient.getCurrentWikiText(it) + .subscribeOn(Schedulers.io()) + .blockingGet() + } + + if (latitude != null) { + buffer.append("\n{{Location|").append(latitude).append("|").append(longitude) + .append("|").append(accuracy).append("}}") + } + + val editedLocation = buffer.toString() + val appendText = wikiText?.let { getFormattedWikiText(it, editedLocation) } + + return Objects.requireNonNull(media.filename) + ?.let { pageEditClient.edit(it, appendText!!, summary) } + } + + /** + * Helps to get formatted wikitext with upgraded location + * @param wikiText current wikitext + * @param editedLocation new location + * @return String + */ + private fun getFormattedWikiText(wikiText: String, editedLocation: String): String { + if (wikiText.contains("filedesc") && wikiText.contains("Location")) { + val fromLocationToEnd = wikiText.substring(wikiText.indexOf("{{Location")) + val firstHalf = wikiText.substring(0, wikiText.indexOf("{{Location")) + val lastHalf = fromLocationToEnd.substring(fromLocationToEnd.indexOf("}}") + 2) + + val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3) + val buffer = StringBuilder() + if (wikiText[wikiText.indexOf("{{Location") - 1] == '\n') { + buffer.append(editedLocation.substring(1)) + } else { + buffer.append(editedLocation) + } + if (startOfSecondSection != -1 && wikiText[startOfSecondSection - 1] != '\n') { + buffer.append("\n") + } + + return firstHalf + buffer + lastHalf + } + if (wikiText.contains("filedesc") && !wikiText.contains("Location")) { + val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3) + + if (startOfSecondSection != -1) { + val firstHalf = wikiText.substring(0, startOfSecondSection) + val lastHalf = wikiText.substring(startOfSecondSection) + val buffer = editedLocation.substring(1) + "\n" + return firstHalf + buffer + lastHalf + } + + return wikiText + editedLocation + } + return "== {{int:filedesc}} ==$editedLocation$wikiText" + } + + /** + * Update coordinates and shows notification about coordinates update + * @param context to be added + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @param result to be added + * @return boolean + */ + private fun showCoordinatesEditNotification( + context: Context, + media: Media, + latitude: String, + longitude: String, + accuracy: String, + result: Boolean + ): Boolean { + val message: String + var title = context.getString(R.string.coordinates_edit_helper_show_edit_title) + + if (result) { + media.coordinates = fr.free.nrw.commons.location.LatLng( + latitude.toDouble(), + longitude.toDouble(), + accuracy.toFloat() + ) + title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title_success) + val coordinatesInMessage = StringBuilder() + val mediaCoordinate = media.coordinates.toString() + coordinatesInMessage.append(mediaCoordinate) + message = context.getString( + R.string.coordinates_edit_helper_show_edit_message, + coordinatesInMessage.toString() + ) + } else { + title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title) + message = context.getString(R.string.coordinates_edit_helper_edit_message_else) + } + + val urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.filename + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)) + notificationHelper.showNotification( + context, + title, + message, + NOTIFICATION_EDIT_COORDINATES, + browserIntent + ) + return result + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt new file mode 100644 index 000000000..b3ef36318 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity class for Not For Upload status. + */ +@Entity(tableName = "images_not_for_upload_table") +data class NotForUploadStatus( + /** + * Original image sha1. + */ + @PrimaryKey + val imageSHA1: String, +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt new file mode 100644 index 000000000..b75a6e1d4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt @@ -0,0 +1,53 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** + * Dao class for Not For Upload + */ +@Dao +abstract class NotForUploadStatusDao { + /** + * Insert into Not For Upload status. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(notForUploadStatus: NotForUploadStatus) + + /** + * Delete Not For Upload status entry. + */ + @Delete + abstract suspend fun delete(notForUploadStatus: NotForUploadStatus) + + /** + * Query Not For Upload status with image sha1. + */ + @Query("SELECT * FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun getFromImageSHA1(imageSHA1: String): NotForUploadStatus? + + /** + * Asynchronous image sha1 query. + */ + suspend fun getNotForUploadFromImageSHA1(imageSHA1: String): NotForUploadStatus? = getFromImageSHA1(imageSHA1) + + /** + * Deletion Not For Upload status with image sha1. + */ + @Query("DELETE FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun deleteWithImageSHA1(imageSHA1: String) + + /** + * Asynchronous image sha1 deletion. + */ + suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) = deleteWithImageSHA1(imageSHA1) + + /** + * Check whether the imageSHA1 is present in database + */ + @Query("SELECT COUNT() FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun find(imageSHA1: String): Int +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt new file mode 100644 index 000000000..7f635ed95 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt @@ -0,0 +1,34 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.Date + +/** + * Entity class for Uploaded Status. + */ +@Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)]) +data class UploadedStatus( + /** + * Original image sha1. + */ + @PrimaryKey + val imageSHA1: String, + /** + * Modified image sha1 (after exif changes). + */ + val modifiedImageSHA1: String, + /** + * imageSHA1 query result from API. + */ + var imageResult: Boolean, + /** + * modifiedImageSHA1 query result from API. + */ + var modifiedImageResult: Boolean, + /** + * lastUpdated for data validation. + */ + var lastUpdated: Date? = null, +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt new file mode 100644 index 000000000..378af5b8d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt @@ -0,0 +1,78 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import java.util.Calendar + +/** + * UploadedStatusDao for Custom Selector. + */ +@Dao +abstract class UploadedStatusDao { + /** + * Insert into uploaded status. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(uploadedStatus: UploadedStatus) + + /** + * Update uploaded status entry. + */ + @Update + abstract suspend fun update(uploadedStatus: UploadedStatus) + + /** + * Delete uploaded status entry. + */ + @Delete + abstract suspend fun delete(uploadedStatus: UploadedStatus) + + /** + * Query uploaded status with image sha1. + */ + @Query("SELECT * FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun getFromImageSHA1(imageSHA1: String): UploadedStatus? + + /** + * Query uploaded status with modified image sha1. + */ + @Query("SELECT * FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) ") + abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1: String): UploadedStatus? + + /** + * Asynchronous insert into uploaded status table. + */ + suspend fun insertUploaded(uploadedStatus: UploadedStatus) { + uploadedStatus.lastUpdated = Calendar.getInstance().time + insert(uploadedStatus) + } + + /** + * Check whether the imageSHA1 is present in database + */ + @Query("SELECT COUNT() FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) AND imageResult = (:imageResult) ") + abstract suspend fun findByImageSHA1( + imageSHA1: String, + imageResult: Boolean, + ): Int + + /** + * Check whether the modifiedImageSHA1 is present in database + */ + @Query( + "SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ", + ) + abstract suspend fun findByModifiedImageSHA1( + modifiedImageSHA1: String, + modifiedImageResult: Boolean, + ): Int + + /** + * Asynchronous image sha1 query. + */ + suspend fun getUploadedFromImageSHA1(imageSHA1: String): UploadedStatus? = getFromImageSHA1(imageSHA1) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt new file mode 100644 index 000000000..e03b4da3c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.customselector.helper + +/** + * Stores constants related to custom image selector + */ +object CustomSelectorConstants { + const val BUCKET_ID = "bucket_id" + const val TOTAL_SELECTED_IMAGES = "total_selected_images" + const val PRESENT_POSITION = "present_position" + const val NEW_SELECTED_IMAGES = "new_selected_images" + const val SHOULD_REFRESH = "should_refresh" + const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch" +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt new file mode 100644 index 000000000..9a949b1cf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -0,0 +1,249 @@ +package fr.free.nrw.commons.customselector.helper + +import android.content.ContentUris +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AlertDialog +import fr.free.nrw.commons.R +import timber.log.Timber +import java.io.File + +object FolderDeletionHelper { + + /** + * Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it. + * + * @param context The context used to show the confirmation dialog and manage deletion. + * @param folder The folder to be deleted. + * @param onDeletionComplete Callback invoked with `true` if the folder was + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. + * successfully deleted, `false` otherwise. + */ + fun confirmAndDeleteFolder( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher, + onDeletionComplete: (Boolean) -> Unit) { + + //don't show this dialog on API 30+, it's handled automatically using MediaStore + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val success = trashImagesInFolder(context, folder, trashFolderLauncher) + onDeletionComplete(success) + } else { + val imagePaths = listImagesInFolder(context, folder) + val imageCount = imagePaths.size + val folderPath = folder.absolutePath + + AlertDialog.Builder(context) + .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) + .setCancelable(false) + .setMessage( + context.getString( + R.string.custom_selector_confirm_deletion_message, + folderPath, + imageCount + ) + ) + .setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ -> + + //proceed with deletion if user confirms + val success = deleteImagesLegacy(imagePaths) + onDeletionComplete(success) + } + .setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ -> + dialog.dismiss() + onDeletionComplete(false) + } + .show() + } + } + + /** + * Moves all images in a specified folder (but not within its subfolders) to the trash on + * devices running Android 11 (API level 30) and above. + * + * @param context The context used to access the content resolver. + * @param folder The folder whose top-level images are to be moved to the trash. + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash + * request. + * @return `true` if the trash request was initiated successfully, `false` otherwise. + */ + private fun trashImagesInFolder( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher): Boolean + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val urisToTrash = mutableListOf() + + val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + // select images contained in the folder but not within subfolders + val selection = + "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?" + val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%") + + contentResolver.query( + mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection, + selectionArgs, null + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val fileUri = ContentUris.withAppendedId(mediaUri, id) + urisToTrash.add(fileUri) + } + } + + + //proceed with trashing if we have valid URIs + if (urisToTrash.isNotEmpty()) { + try { + val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true) + val intentSenderRequest = + IntentSenderRequest.Builder(trashRequest.intentSender).build() + trashFolderLauncher.launch(intentSenderRequest) + return true + } catch (e: SecurityException) { + Timber.tag("DeleteFolder").e( + context.getString( + R.string.custom_selector_error_trashing_folder_contents, + e.message + ) + ) + } + } + return false + } + + + /** + * Lists all image file paths in the specified folder, excluding any subfolders. + * + * @param context The context used to access the content resolver. + * @param folder The folder whose top-level images are to be listed. + * @return A list of file paths (as Strings) pointing to the images in the specified folder. + */ + private fun listImagesInFolder(context: Context, folder: File): List { + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val selection = + "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?" + val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%") + val imagePaths = mutableListOf() + + contentResolver.query( + mediaUri, arrayOf(MediaStore.MediaColumns.DATA), selection, + selectionArgs, null + )?.use { cursor -> + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + while (cursor.moveToNext()) { + val imagePath = cursor.getString(dataColumn) + imagePaths.add(imagePath) + } + } + return imagePaths + } + + + + /** + * Refreshes the MediaStore for a specified folder, updating the system to recognize any changes + * + * @param context The context used to access the MediaScannerConnection. + * @param folder The folder to refresh in the MediaStore. + */ + fun refreshMediaStore(context: Context, folder: File) { + MediaScannerConnection.scanFile( + context, + arrayOf(folder.absolutePath), + null + ) { _, _ -> } + } + + + + /** + * Deletes a list of image files specified by their paths, on + * Android 10 (API level 29) and below. + * + * @param imagePaths A list of absolute file paths to image files that need to be deleted. + * @return `true` if all the images are successfully deleted, `false` otherwise. + */ + private fun deleteImagesLegacy(imagePaths: List): Boolean { + var result = true + imagePaths.forEach { + val imageFile = File(it) + val deleted = imageFile.exists() && imageFile.delete() + result = result && deleted + } + return result + } + + + /** + * Retrieves the absolute path of a folder given its unique identifier (bucket ID). + * + * @param context The context used to access the content resolver. + * @param folderId The unique identifier (bucket ID) of the folder. + * @return The absolute path of the folder as a `String`, or `null` if the folder is not found. + */ + fun getFolderPath(context: Context, folderId: Long): String? { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?" + val selectionArgs = arrayOf(folderId.toString()) + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)) + return File(fullPath).parent + } + } + return null + } + + /** + * Displays an error message to the user and logs it for debugging purposes. + * + * @param context The context used to display the Toast. + * @param message The error message to display and log. + * @param folderName The name of the folder to delete. + */ + fun showError(context: Context, message: String, folderName: String) { + Toast.makeText(context, + context.getString(R.string.custom_selector_folder_deleted_failure, folderName), + Toast.LENGTH_SHORT).show() + Timber.tag("DeleteFolder").e(message) + } + + /** + * Displays a success message to the user. + * + * @param context The context used to display the Toast. + * @param message The success message to display. + * @param folderName The name of the folder to delete. + */ + fun showSuccess(context: Context, message: String, folderName: String) { + Toast.makeText(context, + context.getString(R.string.custom_selector_folder_deleted_success, folderName), + Toast.LENGTH_SHORT).show() + Timber.tag("DeleteFolder").d(message) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt new file mode 100644 index 000000000..5df123ad2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -0,0 +1,98 @@ +package fr.free.nrw.commons.customselector.helper + +import fr.free.nrw.commons.customselector.model.Folder +import fr.free.nrw.commons.customselector.model.Image + +/** + * Image Helper object, includes all the static functions and variables required by custom selector. + */ +object ImageHelper { + /** + * Custom selector preference key + */ + const val CUSTOM_SELECTOR_PREFERENCE_KEY: String = "custom_selector" + + /** + * Switch state preference key + */ + const val SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY: String = "show_already_actioned_images" + + /** + * Returns the list of folders from given image list. + */ + fun folderListFromImages(images: List): ArrayList { + val folderMap: MutableMap = LinkedHashMap() + for (image in images) { + val bucketId = image.bucketId + val bucketName = image.bucketName + var folder = folderMap[bucketId] + if (folder == null) { + folder = Folder(bucketId, bucketName) + folderMap[bucketId] = folder + } + folder.images.add(image) + } + return ArrayList(folderMap.values) + } + + /** + * Filters the images based on the given bucketId (folder) + */ + fun filterImages( + images: ArrayList, + bukketId: Long?, + ): ArrayList { + if (bukketId == null) return images + + val filteredImages = arrayListOf() + for (image in images) { + if (image.bucketId == bukketId) { + filteredImages.add(image) + } + } + return filteredImages + } + + /** + * getIndex: Returns the index of image in given list. + */ + fun getIndex( + list: ArrayList, + image: Image, + ): Int = list.indexOf(image) + + /** + * getIndex: Returns the index of image in given list. + */ + fun getIndexFromId( + list: ArrayList, + imageId: Long, + ): Int { + for (i in list) { + if (i.id == imageId) { + return list.indexOf(i) + } + } + return 0 + } + + /** + * Gets the list of indices from the master list. + */ + fun getIndexList( + list: ArrayList, + masterList: ArrayList, + ): ArrayList { + // Can be optimised as masterList is sorted by time. + + val indexes = arrayListOf() + for (image in list) { + val index = getIndex(masterList, image) + if (index == -1) { + continue + } + indexes.add(index) + } + return indexes + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt new file mode 100644 index 000000000..05159c56b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt @@ -0,0 +1,110 @@ +package fr.free.nrw.commons.customselector.helper + +import android.content.Context +import android.util.DisplayMetrics +import android.view.Display +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import kotlin.math.abs + +/** + * Class for detecting swipe gestures + */ +open class OnSwipeTouchListener( + context: Context?, +) : View.OnTouchListener { + private val gestureDetector: GestureDetector + + private val swipeThresholdHeight = (getScreenResolution(context!!)).second / 3 + private val swipeThresholdWidth = (getScreenResolution(context!!)).first / 3 + private val swipeVelocityThreshold = 1000 + + override fun onTouch( + view: View?, + motionEvent: MotionEvent, + ): Boolean = gestureDetector.onTouchEvent(motionEvent) + + fun getScreenResolution(context: Context): Pair { + val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display: Display = wm.getDefaultDisplay() + val metrics = DisplayMetrics() + display.getMetrics(metrics) + val width: Int = metrics.widthPixels + val height: Int = metrics.heightPixels + return width to height + } + + inner class GestureListener : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean = true + + /** + * Detects the gestures + */ + override fun onFling( + event1: MotionEvent?, + event2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + try { + val diffY: Float = event2.y - (event1?.y ?: event2.y) + val diffX: Float = event2.x - (event1?.x ?: event2.x) + if (abs(diffX) > abs(diffY)) { + if (abs(diffX) > swipeThresholdWidth && + abs(velocityX) > + swipeVelocityThreshold + ) { + if (diffX > 0) { + onSwipeRight() + } else { + onSwipeLeft() + } + } + } else { + if (abs(diffY) > swipeThresholdHeight && + abs(velocityY) > + swipeVelocityThreshold + ) { + if (diffY > 0) { + onSwipeDown() + } else { + onSwipeUp() + } + } + } + } catch (exception: Exception) { + exception.printStackTrace() + } + return false + } + } + + /** + * Swipe right to view previous image + */ + open fun onSwipeRight() {} + + /** + * Swipe left to view next image + */ + open fun onSwipeLeft() {} + + /** + * Swipe up to select the picture (the equivalent of tapping it in non-fullscreen mode) + * and show the next picture skipping pictures that have either already been uploaded or + * marked as not for upload + */ + open fun onSwipeUp() {} + + /** + * Swipe down to mark that picture as "Not for upload" (the equivalent of selecting it then + * tapping "Mark as not for upload" in non-fullscreen mode), and show the next picture. + */ + open fun onSwipeDown() {} + + init { + gestureDetector = GestureDetector(context, GestureListener()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt new file mode 100644 index 000000000..be808c3f0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.customselector.listeners + +/** + * Custom Selector Folder Click Listener + */ +interface FolderClickListener { + /** + * onFolderClick + * @param folderId : folder id of the folder. + * @param folderName : folder name of the folder. + * @param lastItemId : last scroll position in the folder. + */ + fun onFolderClick( + folderId: Long, + folderName: String, + lastItemId: Long, + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt new file mode 100644 index 000000000..78ce46c6e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.customselector.listeners + +import fr.free.nrw.commons.customselector.model.Image + +/** + * Custom Selector Image Loader Listener + * responds to the device image query. + */ +interface ImageLoaderListener { + /** + * On image loaded + * @param images : queried device images. + */ + fun onImageLoaded(images: ArrayList) + + /** + * On failed + * @param throwable : throwable exception on failure. + */ + fun onFailed(throwable: Throwable) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt new file mode 100644 index 000000000..88f90d2e9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt @@ -0,0 +1,31 @@ +package fr.free.nrw.commons.customselector.listeners + +import fr.free.nrw.commons.customselector.model.Image + +/** + * Custom selector Image select listener + */ +interface ImageSelectListener { + /** + * onSelectedImagesChanged + * @param selectedImages : new selected images. + * @param selectedNotForUploadImages : number of selected not for upload images + */ + fun onSelectedImagesChanged( + selectedImages: ArrayList, + selectedNotForUploadImages: Int, + ) + + /** + * Called when the user performs a long press on an image. + * + * @param position The index of the pressed image in the list. + * @param images The list of all available images. + * @param selectedImages The currently selected images. + */ + fun onLongPress( + position: Int, + images: ArrayList, + selectedImages: ArrayList, + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt new file mode 100644 index 000000000..da526be35 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.customselector.listeners + +import fr.free.nrw.commons.customselector.model.Image + +/** + * Interface to pass data between fragment and activity + */ +interface PassDataListener { + fun passSelectedImages( + selectedImages: ArrayList, + shouldRefresh: Boolean, + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt new file mode 100644 index 000000000..a6b4f3dce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.customselector.listeners + +/** + * Refresh UI Listener + */ +interface RefreshUIListener { + /** + * Refreshes the data in adapter + */ + fun refresh() +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt new file mode 100644 index 000000000..856c8de88 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.customselector.model + +/** + * sealed class Callback Status. + * Current status of the device image query. + */ +sealed class CallbackStatus { + /** + IDLE : The callback is idle , doing nothing. + */ + data object IDLE : CallbackStatus() + + /** + FETCHING : Fetching images. + */ + data object FETCHING : CallbackStatus() + + /** + SUCCESS : Success fetching images. + */ + data object SUCCESS : CallbackStatus() +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt new file mode 100644 index 000000000..4bf295f4c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt @@ -0,0 +1,49 @@ +package fr.free.nrw.commons.customselector.model + +/** + * Custom selector data class Folder. + */ +data class Folder( + /** + bucketId : Unique directory id, eg 540528482 + */ + var bucketId: Long, + /** + name : bucket/folder name, eg Camera + */ + var name: String, + /** + images : folder images, list of all images under this folder. + */ + var images: ArrayList = arrayListOf(), +) { + /** + * Indicates whether some other object is "equal to" this one. + */ + override fun equals(other: Any?): Boolean { + if (javaClass != other?.javaClass) { + return false + } + + other as Folder + + if (bucketId != other.bucketId) { + return false + } + if (name != other.name) { + return false + } + if (images != other.images) { + return false + } + + return true + } + + override fun hashCode(): Int { + var result = bucketId.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + images.hashCode() + return result + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt new file mode 100644 index 000000000..a172f28e2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt @@ -0,0 +1,142 @@ +package fr.free.nrw.commons.customselector.model + +import android.net.Uri +import android.os.Build +import android.os.Parcel +import android.os.Parcelable + +/** + * Custom selector data class Image. + */ +data class Image( + /** + id : Unique image id, primary key of image in device, eg 104950 + */ + var id: Long, + /** + name : Name of the image with extension, eg CommonsLogo.jpeg + */ + var name: String, + /** + uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10) + */ + var uri: Uri, + /** + path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg + */ + var path: String, + /** + bucketId : bucketId of folder, eg 540528482 + */ + var bucketId: Long = 0, + /** + bucketName : name of folder, eg Camera + */ + var bucketName: String = "", + /** + sha1 : sha1 of original image. + */ + var sha1: String = "", + /** + * date: Creation date of the image to show it inside the bubble during bubble scroll. + */ + var date: String = "", +) : Parcelable { + /** + default parcelable constructor. + */ + constructor(parcel: Parcel) : + this( + parcel.readLong(), + parcel.readString()!!, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readParcelable(Uri::class.java.classLoader, Uri::class.java)!! + } else { + @Suppress("DEPRECATION") + parcel.readParcelable(Uri::class.java.classLoader)!! + }, + parcel.readString()!!, + parcel.readLong(), + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!!, + ) + + /** + Write to parcel method. + */ + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { + parcel.writeLong(id) + parcel.writeString(name) + parcel.writeParcelable(uri, flags) + parcel.writeString(path) + parcel.writeLong(bucketId) + parcel.writeString(bucketName) + parcel.writeString(sha1) + parcel.writeString(date) + } + + /** + * Describe the kinds of special objects contained in this Parcelable + */ + override fun describeContents(): Int = 0 + + /** + * Indicates whether some other object is "equal to" this one. + */ + override fun equals(other: Any?): Boolean { + if (javaClass != other?.javaClass) { + return false + } + + other as Image + + if (id != other.id) { + return false + } + if (name != other.name) { + return false + } + if (uri != other.uri) { + return false + } + if (path != other.path) { + return false + } + if (bucketId != other.bucketId) { + return false + } + if (bucketName != other.bucketName) { + return false + } + if (sha1 != other.sha1) { + return false + } + + return true + } + + /** + * Parcelable companion object + */ + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Image = Image(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + bucketId.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + uri.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + bucketName.hashCode() + result = 31 * result + sha1.hashCode() + result = 31 * result + date.hashCode() + return result + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt new file mode 100644 index 000000000..5cccccae6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.customselector.model + +/** + * Custom selector data class Result. + */ +data class Result( + /** + * CallbackStatus : stores the result status + */ + val status: CallbackStatus, + /** + * Images : images retrieved + */ + val images: ArrayList, +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt new file mode 100644 index 000000000..87f68a3e1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -0,0 +1,164 @@ +package fr.free.nrw.commons.customselector.ui.adapter + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.listeners.FolderClickListener +import fr.free.nrw.commons.customselector.model.Folder +import fr.free.nrw.commons.customselector.model.Image + +/** + * Custom selector FolderAdapter. + */ +class FolderAdapter( + /** + * Application context. + */ + context: Context, + /** + * Folder Click listener for click events. + */ + private val itemClickListener: FolderClickListener, +) : RecyclerViewAdapter(context) { + /** + * List of folders. + */ + private var folders: MutableList = mutableListOf() + + /** + * Create view holder, returns View holder item. + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): FolderViewHolder { + val itemView = inflater.inflate(R.layout.item_custom_selector_folder, parent, false) + return FolderViewHolder(itemView) + } + + /** + * Bind view holder, setup the item view, title, count and click listener + */ + override fun onBindViewHolder( + holder: FolderViewHolder, + position: Int, + ) { + val folder = folders[position] + val toBeRemoved = ArrayList() + + for (image in folder.images) { + // Remove all the top images that do not exist anymore + if (context.contentResolver.getType(image.uri) == null) { + // File not found + toBeRemoved.add(image) + } else { + break + } + } + holder.image.setImageDrawable(null) + folder.images.removeAll(toBeRemoved) + val count = folder.images.size + + if (count == 0 && folders.size > 0) { + // Folder is empty, remove folder from the adapter. + holder.itemView.post { + val updatePosition = folders.indexOf(folder) + if (updatePosition != -1) { + folders.removeAt(updatePosition) + notifyItemRemoved(updatePosition) + notifyItemRangeChanged(updatePosition, folders.size) + } + } + } else { + val previewImage = folder.images[0] + Glide.with(holder.image).load(previewImage.uri).into(holder.image) + holder.name.text = folder.name + holder.count.text = count.toString() + holder.itemView.setOnClickListener { + itemClickListener.onFolderClick(folder.bucketId, folder.name, 0) + } + } + } + + /** + * Initialise the data set. + */ + fun init(newFolders: List) { + val oldFolderList: MutableList = folders + val newFolderList = newFolders.toMutableList() + val diffResult = + DiffUtil.calculateDiff( + FoldersDiffCallback(oldFolderList, newFolderList), + ) + folders = newFolderList + diffResult.dispatchUpdatesTo(this) + } + + /** + * returns item count. + */ + override fun getItemCount(): Int = folders.size + + /** + * Folder view holder. + */ + class FolderViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + /** + * Folder thumbnail image view. + */ + val image: ImageView = itemView.findViewById(R.id.folder_thumbnail) + + /** + * Folder/album name + */ + val name: TextView = itemView.findViewById(R.id.folder_name) + + /** + * Item count in Folder/Item + */ + val count: TextView = itemView.findViewById(R.id.folder_count) + } + + /** + * DiffUtilCallback. + */ + class FoldersDiffCallback( + var oldFolders: MutableList, + var newFolders: MutableList, + ) : DiffUtil.Callback() { + /** + * Returns the size of the old list. + */ + override fun getOldListSize(): Int = oldFolders.size + + /** + * Returns the size of the new list. + */ + override fun getNewListSize(): Int = newFolders.size + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + */ + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId + + /** + * Called by the DiffUtil when it wants to check whether two items have the same data. + * DiffUtil uses this information to detect if the contents of an item has changed. + */ + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt new file mode 100644 index 000000000..c3ef4a784 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -0,0 +1,639 @@ +package fr.free.nrw.commons.customselector.ui.adapter + +import android.content.Context +import android.content.SharedPreferences +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.constraintlayout.widget.Group +import androidx.recyclerview.widget.DiffUtil +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 +import fr.free.nrw.commons.customselector.listeners.ImageSelectListener +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.selector.ImageLoader +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.TreeMap +import kotlin.collections.ArrayList + +/** + * Custom selector ImageAdapter. + */ +class ImageAdapter( + /** + * Application Context. + */ + context: Context, + /** + * Image select listener for click events on image. + */ + private var imageSelectListener: ImageSelectListener, + /** + * ImageLoader queries images. + */ + private var imageLoader: ImageLoader, +) : RecyclerViewAdapter(context), + FastScrollRecyclerView.SectionedAdapter { + /** + * ImageSelectedOrUpdated payload class. + */ + class ImageSelectedOrUpdated + + /** + * ImageUnselected payload class. + */ + class ImageUnselected + + /** + * Determines whether addition of all actionable images is done or not + */ + private var reachedEndOfFolder: Boolean = false + + /** + * Currently selected images. + */ + private var selectedImages = arrayListOf() + + /** + * Number of selected images that are marked as not for upload + */ + private var numberOfSelectedImagesMarkedAsNotForUpload = 0 + + /** + * List of all images in adapter. + */ + private var images: ArrayList = ArrayList() + + /** + * Stores all images + */ + private var allImages: List = ArrayList() + + /** + * Map to store actionable images + */ + private var actionableImagesMap: TreeMap = TreeMap() + + private var uploadingContributionList: List = ArrayList() + + /** + * Stores already added positions of actionable images + */ + private var alreadyAddedPositions: ArrayList = ArrayList() + + /** + * Next starting index to initiate query to find next actionable image + */ + private var nextImagePosition = 0 + + /** + * Helps to maintain the increasing sequence of the position. eg- 0, 1, 2, 3 + */ + private var imagePositionAsPerIncreasingOrder = 0 + + /** + * Stores the number of images currently visible on the screen + */ + private val _currentImagesCount = MutableStateFlow(0) + val currentImagesCount = _currentImagesCount + + /** + * Stores whether images are being loaded or not + */ + private val _isLoadingImages = MutableStateFlow(false) + val isLoadingImages = _isLoadingImages + + /** + * Coroutine Dispatchers and Scope. + */ + private var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default + private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + private val scope: CoroutineScope = MainScope() + + /** + * Create View holder. + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ImageViewHolder { + val itemView = inflater.inflate(R.layout.item_custom_selector_image, parent, false) + return ImageViewHolder(itemView) + } + + /** + * Bind View holder, load image, selected view, click listeners. + */ + override fun onBindViewHolder( + holder: ImageViewHolder, + position: Int, + ) { + if (images.size == 0) { + return + } + var image = images[position] + holder.image.setImageDrawable(null) + if (context.contentResolver.getType(image.uri) == null) { + // Image does not exist anymore, update adapter. + holder.itemView.post { + val updatedPosition = images.indexOf(image) + images.remove(image) + notifyItemRemoved(updatedPosition) + notifyItemRangeChanged(updatedPosition, images.size) + } + } else { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // Getting selected index when switch is on + val selectedIndex: Int = + if (showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, image) + + // Getting selected index when switch is off + } else if (actionableImagesMap.size > position) { + ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + + // For any other case return -1 + } else { + -1 + } + + val isSelected = selectedIndex != -1 + if (isSelected) { + holder.itemSelected() + } else { + holder.itemUnselected() + } + imageLoader.queryAndSetView( + holder, + image, + ioDispatcher, + defaultDispatcher, + uploadingContributionList, + ) + scope.launch { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + if (!showAlreadyActionedImages) { + // 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, + uploadingContributionList + ) + _isLoadingImages.value = false + // 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 = ArrayList(actionableImagesMap.values) + 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 + // further operations + } else { + Glide + .with(holder.image) + .load(image.uri) + .thumbnail(0.3f) + .into(holder.image) + } + } + + holder.itemView.setOnClickListener { + onThumbnailClicked(position, holder) + } + + // launch media preview on long click. + holder.itemView.setOnLongClickListener { + imageSelectListener.onLongPress(images.indexOf(image), images, selectedImages) + true + } + } + } + + /** + * Process thumbnail for actioned image + */ + suspend fun processThumbnailForActionedImage( + holder: ImageViewHolder, + position: Int, + uploadingContributionList: List, + ) { + _isLoadingImages.value = true + val next = + imageLoader.nextActionableImage( + allImages, + ioDispatcher, + defaultDispatcher, + nextImagePosition, + uploadingContributionList, + ) + + // If next actionable image is found, saves it, as the the search for + // finding next actionable image will start from this position + if (next > -1) { + nextImagePosition = next + 1 + + // If map doesn't contains the next actionable image, that means it's a + // new actionable image, it will put it to the map as actionable images + // and it will load the new image in the view holder + if (!actionableImagesMap.containsKey(next)) { + actionableImagesMap[next] = allImages[next] + alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) + imagePositionAsPerIncreasingOrder++ + _currentImagesCount.value = imagePositionAsPerIncreasingOrder + Glide + .with(holder.image) + .load(allImages[next].uri) + .thumbnail(0.3f) + .into(holder.image) + notifyItemInserted(position) + notifyItemRangeChanged(position, itemCount + 1) + } + + // If next actionable image is not found, that means searching is + // complete till end, and it will stop searching. + } else { + reachedEndOfFolder = true + notifyItemRemoved(position) + } + _isLoadingImages.value = false + } + + /** + * Handles click on thumbnail + */ + private fun onThumbnailClicked( + position: Int, + holder: ImageViewHolder, + ) { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val switchState = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // While switch is turned off, lets user click on image only if the position is + // added inside map + if (!switchState) { + if (actionableImagesMap.size > position) { + selectOrRemoveImage(holder, position) + } + } else { + selectOrRemoveImage(holder, position) + } + } + + /** + * Handle click event on an image, update counter on images. + */ + private fun selectOrRemoveImage( + holder: ImageViewHolder, + position: Int, + ) { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // Getting clicked index from all images index when show_already_actioned_images + // switch is on + if (singleSelection) { + // If single selection mode, clear previous selection and select only the new one + if (selectedImages.isNotEmpty() && (selectedImages[0] != images[position])) { + val prevIndex = images.indexOf(selectedImages[0]) + selectedImages.clear() + notifyItemChanged(prevIndex, ImageUnselected()) + } + } + val clickedIndex: Int = + if (showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, images[position]) + } else { + ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + } + + if (clickedIndex != -1) { + selectedImages.removeAt(clickedIndex) + if (holder.isItemNotForUpload()) { + numberOfSelectedImagesMarkedAsNotForUpload-- + } + notifyItemChanged(position, ImageUnselected()) + // Notify listener of deselection to update UI + imageSelectListener.onSelectedImagesChanged(selectedImages, numberOfSelectedImagesMarkedAsNotForUpload) + } else { + // Prevent adding the same image multiple times + val image = if (showAlreadyActionedImages) images[position] else ArrayList(actionableImagesMap.values)[position] + if (selectedImages.contains(image)) { + return // Image already selected, ignore additional clicks + } + scope.launch(ioDispatcher) { + val imageSHA1 = imageLoader.getSHA1(image, defaultDispatcher) + withContext(Dispatchers.Main) { + if (holder.isItemUploaded()) { + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + return@withContext + } + + if (imageSHA1.isNotEmpty() && imageLoader.getFromUploaded(imageSHA1) != null) { + holder.itemUploaded() + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + return@withContext + } + + if (!holder.isItemUploaded() && imageSHA1.isNotEmpty() && imageLoader.getFromUploaded(imageSHA1) != null) { + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + } + + if (holder.isItemNotForUpload()) { + numberOfSelectedImagesMarkedAsNotForUpload++ + } + selectedImages.add(image) + notifyItemChanged(position, ImageSelectedOrUpdated()) + imageSelectListener.onSelectedImagesChanged(selectedImages, numberOfSelectedImagesMarkedAsNotForUpload) + } + } + } + } + + /** + * Initialize the data set. + */ + fun init( + newImages: List, + fixedImages: List, + emptyMap: TreeMap, + uploadedImages: List = ArrayList(), + ) { + _isLoadingImages.value = true + allImages = fixedImages + val oldImageList: ArrayList = images + val newImageList: ArrayList = ArrayList(newImages) + actionableImagesMap = emptyMap + alreadyAddedPositions = ArrayList() + uploadingContributionList = uploadedImages + nextImagePosition = 0 + reachedEndOfFolder = false + selectedImages = ArrayList() + imagePositionAsPerIncreasingOrder = 0 + _currentImagesCount.value = imagePositionAsPerIncreasingOrder + val diffResult = + DiffUtil.calculateDiff( + ImagesDiffCallback(oldImageList, newImageList), + ) + images = newImageList + diffResult.dispatchUpdatesTo(this) + } + + /** + * Set new selected images + */ + fun setSelectedImages(newSelectedImages: ArrayList) { + selectedImages = ArrayList(newSelectedImages) + imageSelectListener.onSelectedImagesChanged(selectedImages, 0) + } + + /** + * Refresh the data in the adapter + */ + fun refresh( + newImages: List, + fixedImages: List, + uploadingImages: List = ArrayList(), + ) { + numberOfSelectedImagesMarkedAsNotForUpload = 0 + images.clear() + selectedImages = arrayListOf() + 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 + _currentImagesCount.value = imagePositionAsPerIncreasingOrder + 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. + * + * @return The total number of items in this adapter. + */ + override fun getItemCount(): Int { + val sharedPreferences: SharedPreferences = + context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) + val showAlreadyActionedImages = + sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) + + // While switch is on initializes the holder with all images size + return if (showAlreadyActionedImages) { + allImages.size + + // While switch is off and searching for next actionable has ended, initializes the holder + // with size of all actionable images + } else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) { + actionableImagesMap.size + + // While switch is off, initializes the holder with and extra view holder so that finding + // and addition of the next actionable image in the adapter can be continued + } else { + actionableImagesMap.size + 1 + } + } + + fun getImageIdAt(position: Int): Long = images.get(position).id + + /** + * CleanUp function. + */ + fun cleanUp() { + scope.cancel() + } + + /** + * Image view holder. + */ + class ImageViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + val image: ImageView = itemView.findViewById(R.id.image_thumbnail) + 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() { + selectedGroup.visibility = View.VISIBLE + } + + /** + * Item Unselected view. + */ + fun itemUnselected() { + selectedGroup.visibility = View.GONE + } + + /** + * Item Uploaded view. + */ + fun itemUploaded() { + uploadedGroup.visibility = View.VISIBLE + } + + /** + * Item is uploading + */ + fun itemUploading() { + uploadingGroup.visibility = View.VISIBLE + } + + /** + * Item is not for upload view + */ + fun itemNotForUpload() { + notForUploadGroup.visibility = View.VISIBLE + } + + fun isItemUploaded(): Boolean = uploadedGroup.visibility == View.VISIBLE + + /** + * Item is not for upload + */ + fun isItemNotForUpload(): Boolean = notForUploadGroup.visibility == View.VISIBLE + + /** + * Item is not uploading + */ + fun itemNotUploading() { + uploadingGroup.visibility = View.GONE + } + + /** + * Item Not Uploaded view. + */ + fun itemNotUploaded() { + uploadedGroup.visibility = View.GONE + } + + /** + * Item can be uploaded view + */ + fun itemForUpload() { + notForUploadGroup.visibility = View.GONE + } + } + + /** + * DiffUtilCallback. + */ + class ImagesDiffCallback( + var oldImageList: ArrayList, + var newImageList: ArrayList, + ) : DiffUtil.Callback() { + /** + * Returns the size of the old list. + */ + override fun getOldListSize(): Int = oldImageList.size + + /** + * Returns the size of the new list. + */ + override fun getNewListSize(): Int = newImageList.size + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + */ + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = newImageList[newItemPosition].id == oldImageList[oldItemPosition].id + + /** + * Called by the DiffUtil when it wants to check whether two items have the same data. + * DiffUtil uses this information to detect if the contents of an item has changed. + */ + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) + } + + /** + * Returns the text for showing inside the bubble during bubble scroll. + */ + override fun getSectionName(position: Int): String = images[position].date + + private var singleSelection: Boolean = false + + /** + * Set single selection mode + */ + fun setSingleSelection(single: Boolean) { + singleSelection = single + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt new file mode 100644 index 000000000..3318d5890 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt @@ -0,0 +1,14 @@ +package fr.free.nrw.commons.customselector.ui.adapter + +import android.content.Context +import android.view.LayoutInflater +import androidx.recyclerview.widget.RecyclerView + +/** + * Generic Recycler view adapter. + */ +abstract class RecyclerViewAdapter( + val context: Context, +) : RecyclerView.Adapter() { + val inflater: LayoutInflater = LayoutInflater.from(context) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt new file mode 100644 index 000000000..2534b4aeb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -0,0 +1,798 @@ +package fr.free.nrw.commons.customselector.ui.selector + +import android.Manifest +import android.app.Activity +import android.app.Dialog +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.Window +import android.widget.Button +import android.widget.ImageButton +import android.widget.PopupMenu +import android.widget.TextView +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.ViewGroupCompat +import androidx.lifecycle.ViewModelProvider +import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.database.NotForUploadStatus +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants +import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper +import fr.free.nrw.commons.customselector.listeners.FolderClickListener +import fr.free.nrw.commons.customselector.listeners.ImageSelectListener +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding +import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding +import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding +import fr.free.nrw.commons.media.ZoomableActivity +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.FileUtilsWrapper +import fr.free.nrw.commons.utils.CustomSelectorUtils +import fr.free.nrw.commons.utils.applyEdgeToEdgeBottomPaddingInsets +import fr.free.nrw.commons.utils.applyEdgeToEdgeTopInsets +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.lang.Integer.max +import javax.inject.Inject + +/** + * Custom Selector Activity. + */ +class CustomSelectorActivity : + BaseActivity(), + FolderClickListener, + ImageSelectListener { + /** + * ViewBindings + */ + private lateinit var binding: ActivityCustomSelectorBinding + private lateinit var toolbarBinding: CustomSelectorToolbarBinding + private lateinit var bottomSheetBinding: CustomSelectorBottomLayoutBinding + + /** + * View model. + */ + private lateinit var viewModel: CustomSelectorViewModel + + /** + * isImageFragmentOpen is true when the image fragment is in view. + */ + private var isImageFragmentOpen = false + + /** + * Current ImageFragment attributes. + */ + private var bucketId: Long = 0L + private lateinit var bucketName: String + + /** + * Pref for saving selector state. + */ + private lateinit var prefs: SharedPreferences + + /** + * Maximum number of images that can be selected. + */ + private var 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. + */ + @Inject + lateinit var customSelectorViewModelFactory: CustomSelectorViewModelFactory + + /** + * NotForUploadStatus Dao class for database operations + */ + @Inject + lateinit var notForUploadStatusDao: NotForUploadStatusDao + + /** + * FileUtilsWrapper class to get imageSHA1 from uri + */ + @Inject + lateinit var fileUtilsWrapper: FileUtilsWrapper + + /** + * Coroutine Dispatchers and Scope. + */ + private val scope: CoroutineScope = MainScope() + private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + /** + * Image Fragment instance + */ + var imageFragment: ImageFragment? = null + + private var progressDialogText: String = "" + + private var showPartialAccessIndicator by mutableStateOf(false) + + /** + * Show delete button in folder + */ + private var showOverflowMenu = false + + /** + * Waits for confirmation of delete folder + */ + private val startForFolderDeletionResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()){ + result -> onDeleteFolderResultReceived(result) + } + + private val startForResult = registerForActivityResult(StartActivityForResult()){ result -> + onFullScreenDataReceived(result) + } + + + /** + * onCreate Activity, sets theme, initialises the view model, setup view. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_MEDIA_IMAGES, + ) == PackageManager.PERMISSION_DENIED + ) { + showPartialAccessIndicator = true + } + + binding = ActivityCustomSelectorBinding.inflate(layoutInflater) + toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) + bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) + binding.partialAccessIndicator.setContent { + partialStorageAccessIndicator( + isVisible = showPartialAccessIndicator, + onManage = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) + } + }, + modifier = + Modifier + .padding(vertical = 8.dp, horizontal = 4.dp) + .fillMaxWidth(), + ) + } + ViewGroupCompat.installCompatInsetsDispatch(binding.root) + applyEdgeToEdgeTopInsets(toolbarBinding.toolbarLayout) + bottomSheetBinding.bottomLayout.applyEdgeToEdgeBottomPaddingInsets() + val view = binding.root + setContentView(view) + + prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) + viewModel = + ViewModelProvider(this, customSelectorViewModelFactory).get( + CustomSelectorViewModel::class.java, + ) + + // Check for single selection extra + uploadLimit = if (intent.getBooleanExtra(EXTRA_SINGLE_SELECTION, false)) 1 else 20 + + setupViews() + + if (prefs.getBoolean("customSelectorFirstLaunch", true)) { + // show welcome dialog on first launch + showWelcomeDialog() + prefs.edit().putBoolean("customSelectorFirstLaunch", false).apply() + } + + // Open folder if saved in prefs. + if (prefs.contains(FOLDER_ID)) { + val lastOpenFolderId: Long = prefs.getLong(FOLDER_ID, 0L) + val lastOpenFolderName: String? = prefs.getString(FOLDER_NAME, null) + val lastItemId: Long = prefs.getLong(ITEM_ID, 0) + lastOpenFolderName?.let { onFolderClick(lastOpenFolderId, it, lastItemId) } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showPartialAccessIndicator = false + } + } + } + + override fun onResume() { + super.onResume() + fetchData() + } + + /** + * When data will be send from full screen mode, it will be passed to fragment + */ + private fun onFullScreenDataReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK) { + val selectedImages: ArrayList = + result.data!! + .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! + viewModel.selectedImages?.value = selectedImages + } + } + + private fun onDeleteFolderResultReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK){ + FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) + navigateToCustomSelector() + } + } + + + + /** + * Show Custom Selector Welcome Dialog. + */ + private fun showWelcomeDialog() { + val dialog = Dialog(this) + dialog.setCancelable(false) + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + dialog.setContentView(R.layout.custom_selector_info_dialog) + (dialog.findViewById