mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 20:33:53 +01:00
Merge remote-tracking branch 'origin/main' into jetpack-custom-selector
This commit is contained in:
commit
3a3bb4e7c2
712 changed files with 38946 additions and 35850 deletions
22
.github/workflows/android.yml
vendored
22
.github/workflows/android.yml
vendored
|
|
@ -12,17 +12,17 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Cache packages
|
||||
id: cache-packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
|
||||
- name: AVD cache
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
|
|
@ -89,7 +89,7 @@ jobs:
|
|||
run: bash ./gradlew assembleBetaDebug --stacktrace
|
||||
|
||||
- name: Upload betaDebug APK
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betaDebugAPK
|
||||
path: app/build/outputs/apk/beta/debug/app-*.apk
|
||||
|
|
@ -98,7 +98,17 @@ jobs:
|
|||
run: bash ./gradlew assembleProdDebug --stacktrace
|
||||
|
||||
- name: Upload prodDebug APK
|
||||
uses: actions/upload-artifact@v3
|
||||
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
|
||||
|
|
|
|||
41
.github/workflows/build-beta.yml
vendored
Normal file
41
.github/workflows/build-beta.yml
vendored
Normal file
|
|
@ -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
|
||||
96
.github/workflows/comment_artifacts_on_PR.yml
vendored
Normal file
96
.github/workflows/comment_artifacts_on_PR.yml
vendored
Normal file
|
|
@ -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
|
||||
});
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -47,3 +47,4 @@ captures/*
|
|||
# Test and other output
|
||||
app/jacoco.exec
|
||||
app/CommonsContributions
|
||||
app/.*
|
||||
|
|
|
|||
125
CHANGELOG.md
125
CHANGELOG.md
|
|
@ -1,5 +1,130 @@
|
|||
# Wikimedia Commons for Android
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Wikimedia Commons Android app
|
||||

|
||||
[](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amaster)
|
||||
[](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain)
|
||||
[](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw)
|
||||
[](https://codecov.io/gh/commons-app/apps-android-commons)
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ This software is open source, licensed under the [Apache License 2.0][10].
|
|||
|
||||
[1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
|
||||
[2]: https://commons-app.github.io/
|
||||
[3]: https://github.com/commons-app/apps-android-commons/issues
|
||||
[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
|
||||
|
||||
[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation
|
||||
[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation
|
||||
|
|
|
|||
|
|
@ -50,15 +50,18 @@ dependencies {
|
|||
implementation "com.google.android.material:material:1.12.0"
|
||||
implementation 'com.karumi:dexter:5.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
|
||||
// Jetpack Compose
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.10.00')
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.11.00')
|
||||
|
||||
implementation "androidx.activity:activity-compose:1.9.3"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
|
||||
implementation (composeBom)
|
||||
implementation "androidx.compose.runtime:runtime"
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation "androidx.compose.ui:ui-viewbinding"
|
||||
implementation "androidx.compose.ui:ui-graphics"
|
||||
implementation "androidx.compose.ui:ui-tooling"
|
||||
implementation "androidx.compose.foundation:foundation"
|
||||
|
|
@ -93,6 +96,8 @@ dependencies {
|
|||
// Dependency injector
|
||||
implementation "com.google.dagger:dagger-android:$DAGGER_VERSION"
|
||||
implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
|
||||
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
|
||||
|
|
@ -121,7 +126,8 @@ dependencies {
|
|||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0"
|
||||
testImplementation 'com.facebook.soloader:soloader:0.10.5'
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||
debugImplementation("androidx.fragment:fragment-testing:1.6.2")
|
||||
debugImplementation("androidx.fragment:fragment-testing-manifest:1.6.2")
|
||||
androidTestImplementation("androidx.fragment:fragment-testing:1.6.2")
|
||||
testImplementation "commons-io:commons-io:2.6"
|
||||
|
||||
// Android testing
|
||||
|
|
@ -145,7 +151,7 @@ dependencies {
|
|||
implementation "androidx.browser:browser:1.3.0"
|
||||
implementation "androidx.cardview:cardview:1.0.0"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.2"
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.7'
|
||||
implementation "androidx.core:core-ktx:$CORE_KTX_VERSION"
|
||||
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
|
||||
|
||||
|
|
@ -177,8 +183,8 @@ dependencies {
|
|||
testImplementation "androidx.work:work-testing:$work_version"
|
||||
|
||||
//Glide
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
kaptTest "androidx.databinding:databinding-compiler:8.0.2"
|
||||
kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2"
|
||||
|
||||
|
|
@ -214,8 +220,8 @@ android {
|
|||
defaultConfig {
|
||||
//applicationId 'fr.free.nrw.commons'
|
||||
|
||||
versionCode 1040
|
||||
versionName '5.0.2'
|
||||
versionCode 1051
|
||||
versionName '5.4.0'
|
||||
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
|
||||
|
||||
minSdkVersion 21
|
||||
|
|
@ -316,10 +322,12 @@ android {
|
|||
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", "PRIVACY_POLICY_URL", "\"https://commons-app.github.io/privacy-policy\""
|
||||
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\""
|
||||
|
|
@ -333,6 +341,7 @@ android {
|
|||
buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
|
||||
buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
|
||||
buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\""
|
||||
buildConfigField "String", "CREATOR_PROPERTY", "\"P170\""
|
||||
dimension 'tier'
|
||||
}
|
||||
|
||||
|
|
@ -351,10 +360,12 @@ android {
|
|||
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", "PRIVACY_POLICY_URL", "\"https://commons-app.github.io/privacy-policy\""
|
||||
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\""
|
||||
|
|
@ -368,6 +379,7 @@ android {
|
|||
buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
|
||||
buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
|
||||
buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\""
|
||||
buildConfigField "String", "CREATOR_PROPERTY", "\"P253075\""
|
||||
dimension 'tier'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class AboutActivityTest {
|
|||
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!!.codes[0]
|
||||
val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
|
||||
Intents.intended(
|
||||
CoreMatchers.allOf(
|
||||
IntentMatchers.hasAction(Intent.ACTION_VIEW),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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.locationpicker.LocationPickerActivity
|
||||
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
|
||||
import fr.free.nrw.commons.auth.LoginActivity
|
||||
import org.hamcrest.CoreMatchers
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class ContributionsListFragmentUnitTests {
|
|||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
fragment.rvContributionsList = mock()
|
||||
fragment.scrollToTop()
|
||||
verify(fragment.rvContributionsList).smoothScrollToPosition(0)
|
||||
verify(fragment.rvContributionsList)?.smoothScrollToPosition(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -17,7 +17,7 @@ class PasteSensitiveTextInputEditTextTest {
|
|||
@Before
|
||||
fun setup() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
textView = PasteSensitiveTextInputEditText(context)
|
||||
textView = PasteSensitiveTextInputEditText(context!!)
|
||||
}
|
||||
|
||||
// this test has no real value, just % for test code coverage
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@
|
|||
android:theme="@style/LightAppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning"
|
||||
tools:replace="android:appComponentFactory">
|
||||
<activity
|
||||
android:name=".activity.SingleWebViewActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_single_web_view" />
|
||||
<activity
|
||||
android:name=".nearby.WikidataFeedback"
|
||||
android:exported="false" />
|
||||
|
|
@ -172,7 +176,7 @@
|
|||
android:name=".review.ReviewActivity"
|
||||
android:label="@string/title_activity_review" />
|
||||
<activity
|
||||
android:name=".LocationPicker.LocationPickerActivity"
|
||||
android:name=".locationpicker.LocationPickerActivity"
|
||||
android:label="Location Picker" />
|
||||
|
||||
<service
|
||||
|
|
@ -229,12 +233,6 @@
|
|||
android:exported="false"
|
||||
android:label="@string/provider_bookmarks"
|
||||
android:syncable="false" />
|
||||
<provider
|
||||
android:name=".bookmarks.locations.BookmarkLocationsContentProvider"
|
||||
android:authorities="${applicationId}.bookmarks.locations.contentprovider"
|
||||
android:exported="false"
|
||||
android:label="@string/provider_bookmarks_location"
|
||||
android:syncable="false" />
|
||||
<provider
|
||||
android:name=".bookmarks.items.BookmarkItemsContentProvider"
|
||||
android:authorities="${applicationId}.bookmarks.items.contentprovider"
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
import androidx.annotation.NonNull;
|
||||
import fr.free.nrw.commons.databinding.ActivityAboutBinding;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import fr.free.nrw.commons.utils.DialogUtil;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents about screen of this app
|
||||
*/
|
||||
public class AboutActivity extends 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 ActivityAboutBinding binding;
|
||||
|
||||
/**
|
||||
* This method helps in the creation About screen
|
||||
*
|
||||
* @param savedInstanceState Data bundle
|
||||
*/
|
||||
@Override
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
/*
|
||||
Instead of just setting the view with the xml file. We need to use View Binding class.
|
||||
*/
|
||||
binding = ActivityAboutBinding.inflate(getLayoutInflater());
|
||||
final View view = binding.getRoot();
|
||||
setContentView(view);
|
||||
|
||||
setSupportActionBar(binding.toolbarBinding.toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
final String 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")
|
||||
String improveText = String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL);
|
||||
binding.aboutImprove.setHtmlText(improveText);
|
||||
binding.aboutVersion.setText(ConfigUtils.getVersionNameWithSha(getApplicationContext()));
|
||||
|
||||
Utils.setUnderlinedText(binding.aboutFaq, R.string.about_faq, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutRateUs, R.string.about_rate_us, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutUserGuide, R.string.user_guide, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutPrivacyPolicy, R.string.about_privacy_policy, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutTranslate, R.string.about_translate, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutCredits, R.string.about_credits, getApplicationContext());
|
||||
|
||||
/*
|
||||
To set listeners, we can create a separate method and use lambda syntax.
|
||||
*/
|
||||
binding.facebookLaunchIcon.setOnClickListener(this::launchFacebook);
|
||||
binding.githubLaunchIcon.setOnClickListener(this::launchGithub);
|
||||
binding.websiteLaunchIcon.setOnClickListener(this::launchWebsite);
|
||||
binding.aboutRateUs.setOnClickListener(this::launchRatings);
|
||||
binding.aboutCredits.setOnClickListener(this::launchCredits);
|
||||
binding.aboutPrivacyPolicy.setOnClickListener(this::launchPrivacyPolicy);
|
||||
binding.aboutUserGuide.setOnClickListener(this::launchUserGuide);
|
||||
binding.aboutFaq.setOnClickListener(this::launchFrequentlyAskedQuesions);
|
||||
binding.aboutTranslate.setOnClickListener(this::launchTranslate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSupportNavigateUp() {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void launchFacebook(View view) {
|
||||
Intent intent;
|
||||
try {
|
||||
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL));
|
||||
intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME);
|
||||
startActivity(intent);
|
||||
} catch (Exception e) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL));
|
||||
}
|
||||
}
|
||||
|
||||
public void launchGithub(View view) {
|
||||
Intent intent;
|
||||
try {
|
||||
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL));
|
||||
intent.setPackage(Urls.GITHUB_PACKAGE_NAME);
|
||||
startActivity(intent);
|
||||
} catch (Exception e) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
|
||||
}
|
||||
}
|
||||
|
||||
public void launchWebsite(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL));
|
||||
}
|
||||
|
||||
public void launchRatings(View view){
|
||||
Utils.rateApp(this);
|
||||
}
|
||||
|
||||
public void launchCredits(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL));
|
||||
}
|
||||
|
||||
public void launchUserGuide(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL));
|
||||
}
|
||||
|
||||
public void launchPrivacyPolicy(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
|
||||
}
|
||||
|
||||
public void launchFrequentlyAskedQuesions(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
MenuInflater inflater = getMenuInflater();
|
||||
inflater.inflate(R.menu.menu_about, menu);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.share_app_icon:
|
||||
String shareText = String.format(getString(R.string.share_text), Urls.PLAY_STORE_URL_PREFIX + this.getPackageName());
|
||||
Intent sendIntent = new 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;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
public void launchTranslate(View view) {
|
||||
@NonNull List<String> sortedLocalizedNamesRef = CommonsApplication.getInstance().getLanguageLookUpTable().getCanonicalNames();
|
||||
Collections.sort(sortedLocalizedNamesRef);
|
||||
final ArrayAdapter<String> languageAdapter = new ArrayAdapter<>(AboutActivity.this,
|
||||
android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef);
|
||||
final Spinner spinner = new Spinner(AboutActivity.this);
|
||||
spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
spinner.setAdapter(languageAdapter);
|
||||
spinner.setGravity(17);
|
||||
spinner.setPadding(50,0,0,0);
|
||||
|
||||
Runnable positiveButtonRunnable = () -> {
|
||||
String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
|
||||
Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
|
||||
};
|
||||
DialogUtil.showAlertDialog(this,
|
||||
getString(R.string.about_translate_title),
|
||||
getString(R.string.about_translate_message),
|
||||
getString(R.string.about_translate_proceed),
|
||||
getString(R.string.about_translate_cancel),
|
||||
positiveButtonRunnable,
|
||||
() -> {},
|
||||
spinner,
|
||||
true);
|
||||
}
|
||||
|
||||
}
|
||||
209
app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
Normal file
209
app/src/main/java/fr/free/nrw/commons/AboutActivity.kt
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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 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
|
||||
|
||||
/**
|
||||
* 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
|
||||
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()
|
||||
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutFaq, R.string.about_faq,
|
||||
applicationContext
|
||||
)
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutRateUs, R.string.about_rate_us,
|
||||
applicationContext
|
||||
)
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutUserGuide, R.string.user_guide,
|
||||
applicationContext
|
||||
)
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutPrivacyPolicy, R.string.about_privacy_policy,
|
||||
applicationContext
|
||||
)
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutTranslate, R.string.about_translate,
|
||||
applicationContext
|
||||
)
|
||||
Utils.setUnderlinedText(
|
||||
binding!!.aboutCredits, R.string.about_credits,
|
||||
applicationContext
|
||||
)
|
||||
|
||||
/*
|
||||
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(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL))
|
||||
intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME)
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchGithub(view: View?) {
|
||||
val intent: Intent
|
||||
try {
|
||||
intent = Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL))
|
||||
intent.setPackage(Urls.GITHUB_PACKAGE_NAME)
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchWebsite(view: View?) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL))
|
||||
}
|
||||
|
||||
fun launchRatings(view: View?) {
|
||||
Utils.rateApp(this)
|
||||
}
|
||||
|
||||
fun launchCredits(view: View?) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL))
|
||||
}
|
||||
|
||||
fun launchUserGuide(view: View?) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL))
|
||||
}
|
||||
|
||||
fun launchPrivacyPolicy(view: View?) {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
fun launchFrequentlyAskedQuesions(view: View?) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL))
|
||||
}
|
||||
|
||||
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]
|
||||
Utils.handleWebUrl(this@AboutActivity, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode))
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -247,13 +247,17 @@ class CommonsApplication : MultiDexApplication() {
|
|||
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)
|
||||
}
|
||||
BookmarkPicturesDao.Table.onDelete(db)
|
||||
BookmarkLocationsDao.Table.onDelete(db)
|
||||
BookmarkItemsDao.Table.onDelete(db)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import fr.free.nrw.commons.CameraPosition;
|
||||
import fr.free.nrw.commons.Media;
|
||||
|
||||
/**
|
||||
* Helper class for starting the activity
|
||||
*/
|
||||
public final class LocationPicker {
|
||||
|
||||
/**
|
||||
* Getting camera position from the intent using constants
|
||||
*
|
||||
* @param data intent
|
||||
* @return CameraPosition
|
||||
*/
|
||||
public static CameraPosition getCameraPosition(final Intent data) {
|
||||
return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
|
||||
}
|
||||
|
||||
public static class IntentBuilder {
|
||||
|
||||
private final Intent intent;
|
||||
|
||||
/**
|
||||
* Creates a new builder that creates an intent to launch the place picker activity.
|
||||
*/
|
||||
public IntentBuilder() {
|
||||
intent = new Intent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and puts location in intent
|
||||
* @param position CameraPosition
|
||||
* @return LocationPicker.IntentBuilder
|
||||
*/
|
||||
public LocationPicker.IntentBuilder defaultLocation(
|
||||
final CameraPosition position) {
|
||||
intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and puts activity name in intent
|
||||
* @param activity activity key
|
||||
* @return LocationPicker.IntentBuilder
|
||||
*/
|
||||
public LocationPicker.IntentBuilder activityKey(
|
||||
final String activity) {
|
||||
intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and puts media in intent
|
||||
* @param media Media
|
||||
* @return LocationPicker.IntentBuilder
|
||||
*/
|
||||
public LocationPicker.IntentBuilder media(
|
||||
final Media media) {
|
||||
intent.putExtra(LocationPickerConstants.MEDIA, media);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and sets the activity
|
||||
* @param activity Activity
|
||||
* @return Intent
|
||||
*/
|
||||
public Intent build(final Activity activity) {
|
||||
intent.setClass(activity, LocationPickerActivity.class);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,681 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION;
|
||||
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM;
|
||||
import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL;
|
||||
|
||||
import android.Manifest.permission;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.location.LocationManager;
|
||||
import android.os.Bundle;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.text.Html;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.animation.OvershootInterpolator;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import fr.free.nrw.commons.CameraPosition;
|
||||
import fr.free.nrw.commons.CommonsApplication;
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.auth.SessionManager;
|
||||
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
|
||||
import fr.free.nrw.commons.coordinates.CoordinateEditHelper;
|
||||
import fr.free.nrw.commons.filepicker.Constants;
|
||||
import fr.free.nrw.commons.kvstore.BasicKvStore;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.location.LocationPermissionsHelper;
|
||||
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
|
||||
import fr.free.nrw.commons.location.LocationServiceManager;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import fr.free.nrw.commons.utils.DialogUtil;
|
||||
import fr.free.nrw.commons.utils.SystemThemeUtils;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.util.constants.GeoConstants;
|
||||
import org.osmdroid.views.CustomZoomButtonsController;
|
||||
import org.osmdroid.views.overlay.Marker;
|
||||
import org.osmdroid.views.overlay.Overlay;
|
||||
import org.osmdroid.views.overlay.ScaleDiskOverlay;
|
||||
import org.osmdroid.views.overlay.TilesOverlay;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Helps to pick location and return the result with an intent
|
||||
*/
|
||||
public class LocationPickerActivity extends BaseActivity implements
|
||||
LocationPermissionCallback {
|
||||
/**
|
||||
* coordinateEditHelper: helps to edit coordinates
|
||||
*/
|
||||
@Inject
|
||||
CoordinateEditHelper coordinateEditHelper;
|
||||
/**
|
||||
* media : Media object
|
||||
*/
|
||||
private Media media;
|
||||
/**
|
||||
* cameraPosition : position of picker
|
||||
*/
|
||||
private CameraPosition cameraPosition;
|
||||
/**
|
||||
* markerImage : picker image
|
||||
*/
|
||||
private ImageView markerImage;
|
||||
/**
|
||||
* mapView : OSM Map
|
||||
*/
|
||||
private org.osmdroid.views.MapView mapView;
|
||||
/**
|
||||
* tvAttribution : credit
|
||||
*/
|
||||
private AppCompatTextView tvAttribution;
|
||||
/**
|
||||
* activity : activity key
|
||||
*/
|
||||
private String activity;
|
||||
/**
|
||||
* modifyLocationButton : button for start editing location
|
||||
*/
|
||||
Button modifyLocationButton;
|
||||
/**
|
||||
* removeLocationButton : button to remove location metadata
|
||||
*/
|
||||
Button removeLocationButton;
|
||||
/**
|
||||
* showInMapButton : button for showing in map
|
||||
*/
|
||||
TextView showInMapButton;
|
||||
/**
|
||||
* placeSelectedButton : fab for selecting location
|
||||
*/
|
||||
FloatingActionButton placeSelectedButton;
|
||||
/**
|
||||
* fabCenterOnLocation: button for center on location;
|
||||
*/
|
||||
FloatingActionButton fabCenterOnLocation;
|
||||
/**
|
||||
* shadow : imageview of shadow
|
||||
*/
|
||||
private ImageView shadow;
|
||||
/**
|
||||
* largeToolbarText : textView of shadow
|
||||
*/
|
||||
private TextView largeToolbarText;
|
||||
/**
|
||||
* smallToolbarText : textView of shadow
|
||||
*/
|
||||
private TextView smallToolbarText;
|
||||
/**
|
||||
* applicationKvStore : for storing values
|
||||
*/
|
||||
@Inject
|
||||
@Named("default_preferences")
|
||||
public
|
||||
JsonKvStore applicationKvStore;
|
||||
BasicKvStore store;
|
||||
/**
|
||||
* isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly
|
||||
*/
|
||||
@Inject
|
||||
SystemThemeUtils systemThemeUtils;
|
||||
private boolean isDarkTheme;
|
||||
private boolean moveToCurrentLocation;
|
||||
|
||||
@Inject
|
||||
LocationServiceManager locationManager;
|
||||
LocationPermissionsHelper locationPermissionsHelper;
|
||||
|
||||
@Inject
|
||||
SessionManager sessionManager;
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
private static final String CAMERA_POS = "cameraPosition";
|
||||
private static final String ACTIVITY = "activity";
|
||||
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Override
|
||||
protected void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
isDarkTheme = systemThemeUtils.isDeviceInNightMode();
|
||||
moveToCurrentLocation = false;
|
||||
store = new BasicKvStore(this, "LocationPermissions");
|
||||
|
||||
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
|
||||
final ActionBar actionBar = getSupportActionBar();
|
||||
if (actionBar != null) {
|
||||
actionBar.hide();
|
||||
}
|
||||
setContentView(R.layout.activity_location_picker);
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
cameraPosition = getIntent()
|
||||
.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
|
||||
activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY);
|
||||
media = getIntent().getParcelableExtra(LocationPickerConstants.MEDIA);
|
||||
}else{
|
||||
cameraPosition = savedInstanceState.getParcelable(CAMERA_POS);
|
||||
activity = savedInstanceState.getString(ACTIVITY);
|
||||
media = savedInstanceState.getParcelable("sMedia");
|
||||
}
|
||||
bindViews();
|
||||
addBackButtonListener();
|
||||
addPlaceSelectedButton();
|
||||
addCredits();
|
||||
getToolbarUI();
|
||||
addCenterOnGPSButton();
|
||||
|
||||
org.osmdroid.config.Configuration.getInstance().load(getApplicationContext(),
|
||||
PreferenceManager.getDefaultSharedPreferences(getApplicationContext()));
|
||||
|
||||
mapView.setTileSource(TileSourceFactory.WIKIMEDIA);
|
||||
mapView.setTilesScaledToDpi(true);
|
||||
mapView.setMultiTouchControls(true);
|
||||
|
||||
org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put(
|
||||
"Referer", "http://maps.wikimedia.org/"
|
||||
);
|
||||
mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER);
|
||||
mapView.getController().setZoom(ZOOM_LEVEL);
|
||||
mapView.setOnTouchListener((v, event) -> {
|
||||
if (event.getAction() == MotionEvent.ACTION_MOVE) {
|
||||
if (markerImage.getTranslationY() == 0) {
|
||||
markerImage.animate().translationY(-75)
|
||||
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
|
||||
}
|
||||
} else if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
markerImage.animate().translationY(0)
|
||||
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if ("UploadActivity".equals(activity)) {
|
||||
placeSelectedButton.setVisibility(View.GONE);
|
||||
modifyLocationButton.setVisibility(View.VISIBLE);
|
||||
removeLocationButton.setVisibility(View.VISIBLE);
|
||||
showInMapButton.setVisibility(View.VISIBLE);
|
||||
largeToolbarText.setText(getResources().getString(R.string.image_location));
|
||||
smallToolbarText.setText(getResources().
|
||||
getString(R.string.check_whether_location_is_correct));
|
||||
fabCenterOnLocation.setVisibility(View.GONE);
|
||||
markerImage.setVisibility(View.GONE);
|
||||
shadow.setVisibility(View.GONE);
|
||||
assert cameraPosition != null;
|
||||
showSelectedLocationMarker(new GeoPoint(cameraPosition.getLatitude(),
|
||||
cameraPosition.getLongitude()));
|
||||
}
|
||||
setupMapView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the center of the map to the specified coordinates
|
||||
*
|
||||
*/
|
||||
private void moveMapTo(double latitude, double longitude){
|
||||
if(mapView != null && mapView.getController() != null){
|
||||
GeoPoint point = new GeoPoint(latitude, longitude);
|
||||
|
||||
mapView.getController().setCenter(point);
|
||||
mapView.getController().animateTo(point);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the center of the map to the specified coordinates
|
||||
* @param point The GeoPoint object which contains the coordinates to move to
|
||||
*/
|
||||
private void moveMapTo(GeoPoint point){
|
||||
if(point != null){
|
||||
moveMapTo(point.getLatitude(), point.getLongitude());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For showing credits
|
||||
*/
|
||||
private void addCredits() {
|
||||
tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
|
||||
tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
/**
|
||||
* For setting up Dark Theme
|
||||
*/
|
||||
private void darkThemeSetup() {
|
||||
if (isDarkTheme) {
|
||||
shadow.setColorFilter(Color.argb(255, 255, 255, 255));
|
||||
mapView.getOverlayManager().getTilesOverlay()
|
||||
.setColorFilter(TilesOverlay.INVERT_COLORS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicking back button destroy locationPickerActivity
|
||||
*/
|
||||
private void addBackButtonListener() {
|
||||
final ImageView backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button);
|
||||
backButton.setOnClickListener(v -> {
|
||||
finish();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds mapView and location picker icon
|
||||
*/
|
||||
private void bindViews() {
|
||||
mapView = findViewById(R.id.map_view);
|
||||
markerImage = findViewById(R.id.location_picker_image_view_marker);
|
||||
tvAttribution = findViewById(R.id.tv_attribution);
|
||||
modifyLocationButton = findViewById(R.id.modify_location);
|
||||
removeLocationButton = findViewById(R.id.remove_location);
|
||||
showInMapButton = findViewById(R.id.show_in_map);
|
||||
showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase(
|
||||
Locale.ROOT));
|
||||
shadow = findViewById(R.id.location_picker_image_view_shadow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets toolbar color
|
||||
*/
|
||||
private void getToolbarUI() {
|
||||
final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar);
|
||||
largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view);
|
||||
smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view);
|
||||
toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor));
|
||||
}
|
||||
|
||||
private void setupMapView() {
|
||||
requestLocationPermissions();
|
||||
|
||||
//If location metadata is available, move map to that location.
|
||||
if(activity.equals("UploadActivity") || activity.equals("MediaActivity")){
|
||||
moveMapToMediaLocation();
|
||||
} else {
|
||||
//If location metadata is not available, move map to device GPS location.
|
||||
moveMapToGPSLocation();
|
||||
}
|
||||
|
||||
modifyLocationButton.setOnClickListener(v -> onClickModifyLocation());
|
||||
removeLocationButton.setOnClickListener(v -> onClickRemoveLocation());
|
||||
showInMapButton.setOnClickListener(v -> showInMapApp());
|
||||
darkThemeSetup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles onclick event of modifyLocationButton
|
||||
*/
|
||||
private void onClickModifyLocation() {
|
||||
placeSelectedButton.setVisibility(View.VISIBLE);
|
||||
modifyLocationButton.setVisibility(View.GONE);
|
||||
removeLocationButton.setVisibility(View.GONE);
|
||||
showInMapButton.setVisibility(View.GONE);
|
||||
markerImage.setVisibility(View.VISIBLE);
|
||||
shadow.setVisibility(View.VISIBLE);
|
||||
largeToolbarText.setText(getResources().getString(R.string.choose_a_location));
|
||||
smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust));
|
||||
fabCenterOnLocation.setVisibility(View.VISIBLE);
|
||||
removeSelectedLocationMarker();
|
||||
moveMapToMediaLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles onclick event of removeLocationButton
|
||||
*/
|
||||
private void onClickRemoveLocation() {
|
||||
DialogUtil.showAlertDialog(this,
|
||||
getString(R.string.remove_location_warning_title),
|
||||
getString(R.string.remove_location_warning_desc),
|
||||
getString(R.string.continue_message),
|
||||
getString(R.string.cancel), () -> removeLocationFromImage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to remove the location from the picture
|
||||
*/
|
||||
private void removeLocationFromImage() {
|
||||
if (media != null) {
|
||||
getCompositeDisposable().add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext()
|
||||
, media, "0.0", "0.0", "0.0f")
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(s -> {
|
||||
Timber.d("Coordinates are removed from the image");
|
||||
}));
|
||||
}
|
||||
final Intent returningIntent = new Intent();
|
||||
setResult(AppCompatActivity.RESULT_OK, returningIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the location in map app. Map will center on the location metadata, if available.
|
||||
* If there is no location metadata, the map will center on the commons app map center.
|
||||
*/
|
||||
private void showInMapApp() {
|
||||
fr.free.nrw.commons.location.LatLng position = null;
|
||||
|
||||
if(activity.equals("UploadActivity") && cameraPosition != null){
|
||||
//location metadata is available
|
||||
position = new fr.free.nrw.commons.location.LatLng(cameraPosition.getLatitude(),
|
||||
cameraPosition.getLongitude(), 0.0f);
|
||||
} else if(mapView != null){
|
||||
//location metadata is not available
|
||||
position = new fr.free.nrw.commons.location.LatLng(mapView.getMapCenter().getLatitude(),
|
||||
mapView.getMapCenter().getLongitude(), 0.0f);
|
||||
}
|
||||
|
||||
if(position != null){
|
||||
Utils.handleGeoCoordinates(this, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the center of the map to the media's location, if that data
|
||||
* is available.
|
||||
*/
|
||||
private void moveMapToMediaLocation() {
|
||||
if (cameraPosition != null) {
|
||||
|
||||
GeoPoint point = new GeoPoint(cameraPosition.getLatitude(),
|
||||
cameraPosition.getLongitude());
|
||||
|
||||
moveMapTo(point);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the center of the map to the device's GPS location, if that data is available.
|
||||
*/
|
||||
private void moveMapToGPSLocation(){
|
||||
if(locationManager != null){
|
||||
fr.free.nrw.commons.location.LatLng location = locationManager.getLastLocation();
|
||||
|
||||
if(location != null){
|
||||
GeoPoint point = new GeoPoint(location.getLatitude(), location.getLongitude());
|
||||
|
||||
moveMapTo(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the preferable location
|
||||
*/
|
||||
private void addPlaceSelectedButton() {
|
||||
placeSelectedButton = findViewById(R.id.location_chosen_button);
|
||||
placeSelectedButton.setOnClickListener(view -> placeSelected());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the intent with required data
|
||||
*/
|
||||
void placeSelected() {
|
||||
if (activity.equals("NoLocationUploadActivity")) {
|
||||
applicationKvStore.putString(LAST_LOCATION,
|
||||
mapView.getMapCenter().getLatitude()
|
||||
+ ","
|
||||
+ mapView.getMapCenter().getLongitude());
|
||||
applicationKvStore.putString(LAST_ZOOM, mapView.getZoomLevel() + "");
|
||||
}
|
||||
|
||||
if (media == null) {
|
||||
final Intent returningIntent = new Intent();
|
||||
returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
|
||||
new CameraPosition(mapView.getMapCenter().getLatitude(),
|
||||
mapView.getMapCenter().getLongitude(), 14.0));
|
||||
setResult(AppCompatActivity.RESULT_OK, returningIntent);
|
||||
} else {
|
||||
updateCoordinates(String.valueOf(mapView.getMapCenter().getLatitude()),
|
||||
String.valueOf(mapView.getMapCenter().getLongitude()),
|
||||
String.valueOf(0.0f));
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched coordinates are replaced with existing coordinates by a POST API call.
|
||||
* @param Latitude to be added
|
||||
* @param Longitude to be added
|
||||
* @param Accuracy to be added
|
||||
*/
|
||||
public void updateCoordinates(final String Latitude, final String Longitude,
|
||||
final String Accuracy) {
|
||||
if (media == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
getCompositeDisposable().add(
|
||||
coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media,
|
||||
Latitude, Longitude, Accuracy)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(s -> {
|
||||
Timber.d("Coordinates are added.");
|
||||
}));
|
||||
} catch (Exception e) {
|
||||
if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) {
|
||||
final String username = sessionManager.getUserName();
|
||||
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
|
||||
this,
|
||||
getString(R.string.invalid_login_message),
|
||||
username
|
||||
);
|
||||
|
||||
CommonsApplication.getInstance().clearApplicationData(
|
||||
this, logoutListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center the camera on the last saved location
|
||||
*/
|
||||
private void addCenterOnGPSButton() {
|
||||
fabCenterOnLocation = findViewById(R.id.center_on_gps);
|
||||
fabCenterOnLocation.setOnClickListener(view -> {
|
||||
moveToCurrentLocation = true;
|
||||
requestLocationPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds selected location marker on the map
|
||||
*/
|
||||
private void showSelectedLocationMarker(GeoPoint point) {
|
||||
Drawable icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker);
|
||||
Marker marker = new Marker(mapView);
|
||||
marker.setPosition(point);
|
||||
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM);
|
||||
marker.setIcon(icon);
|
||||
marker.setInfoWindow(null);
|
||||
mapView.getOverlays().add(marker);
|
||||
mapView.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes selected location marker from the map
|
||||
*/
|
||||
private void removeSelectedLocationMarker() {
|
||||
List<Overlay> overlays = mapView.getOverlays();
|
||||
for (int i = 0; i < overlays.size(); i++) {
|
||||
if (overlays.get(i) instanceof Marker) {
|
||||
Marker item = (Marker) overlays.get(i);
|
||||
if (cameraPosition.getLatitude() == item.getPosition().getLatitude()
|
||||
&& cameraPosition.getLongitude() == item.getPosition().getLongitude()) {
|
||||
mapView.getOverlays().remove(i);
|
||||
mapView.invalidate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center the map at user's current location
|
||||
*/
|
||||
private void requestLocationPermissions() {
|
||||
locationPermissionsHelper = new LocationPermissionsHelper(
|
||||
this, locationManager, this);
|
||||
locationPermissionsHelper.requestForLocationAccess(R.string.location_permission_title,
|
||||
R.string.upload_map_location_access);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(final int requestCode,
|
||||
@NonNull final String[] permissions,
|
||||
@NonNull final int[] grantResults) {
|
||||
if (requestCode == Constants.RequestCodes.LOCATION
|
||||
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
onLocationPermissionGranted();
|
||||
} else {
|
||||
onLocationPermissionDenied(getString(R.string.upload_map_location_access));
|
||||
}
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
mapView.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
mapView.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLocationPermissionDenied(String toastMessage) {
|
||||
if (!ActivityCompat.shouldShowRequestPermissionRationale(this,
|
||||
permission.ACCESS_FINE_LOCATION)) {
|
||||
if (!locationPermissionsHelper.checkLocationPermission(this)) {
|
||||
if (store.getBoolean("isPermissionDenied", false)) {
|
||||
// means user has denied location permission twice or checked the "Don't show again"
|
||||
locationPermissionsHelper.showAppSettingsDialog(this,
|
||||
R.string.upload_map_location_access);
|
||||
} else {
|
||||
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
store.putBoolean("isPermissionDenied", true);
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLocationPermissionGranted() {
|
||||
if (moveToCurrentLocation || !(activity.equals("MediaActivity"))) {
|
||||
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
|
||||
locationManager.requestLocationUpdatesFromProvider(
|
||||
LocationManager.NETWORK_PROVIDER);
|
||||
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
|
||||
addMarkerAtGPSLocation();
|
||||
} else {
|
||||
addMarkerAtGPSLocation();
|
||||
locationPermissionsHelper.showLocationOffDialog(this,
|
||||
R.string.ask_to_turn_location_on_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a marker to the map at the most recent GPS location
|
||||
* (which may be the current GPS location).
|
||||
*/
|
||||
private void addMarkerAtGPSLocation() {
|
||||
fr.free.nrw.commons.location.LatLng currLocation = locationManager.getLastLocation();
|
||||
if (currLocation != null) {
|
||||
GeoPoint currLocationGeopoint = new GeoPoint(currLocation.getLatitude(),
|
||||
currLocation.getLongitude());
|
||||
addLocationMarker(currLocationGeopoint);
|
||||
markerImage.setTranslationY(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void addLocationMarker(GeoPoint geoPoint) {
|
||||
if (moveToCurrentLocation) {
|
||||
mapView.getOverlays().clear();
|
||||
}
|
||||
ScaleDiskOverlay diskOverlay =
|
||||
new ScaleDiskOverlay(this,
|
||||
geoPoint, 2000, GeoConstants.UnitOfMeasure.foot);
|
||||
Paint circlePaint = new Paint();
|
||||
circlePaint.setColor(Color.rgb(128, 128, 128));
|
||||
circlePaint.setStyle(Paint.Style.STROKE);
|
||||
circlePaint.setStrokeWidth(2f);
|
||||
diskOverlay.setCirclePaint2(circlePaint);
|
||||
Paint diskPaint = new Paint();
|
||||
diskPaint.setColor(Color.argb(40, 128, 128, 128));
|
||||
diskPaint.setStyle(Paint.Style.FILL_AND_STROKE);
|
||||
diskOverlay.setCirclePaint1(diskPaint);
|
||||
diskOverlay.setDisplaySizeMin(900);
|
||||
diskOverlay.setDisplaySizeMax(1700);
|
||||
mapView.getOverlays().add(diskOverlay);
|
||||
org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker(
|
||||
mapView);
|
||||
startMarker.setPosition(geoPoint);
|
||||
startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER,
|
||||
org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM);
|
||||
startMarker.setIcon(
|
||||
ContextCompat.getDrawable(this, R.drawable.current_location_marker));
|
||||
startMarker.setTitle("Your Location");
|
||||
startMarker.setTextLabelFontSize(24);
|
||||
mapView.getOverlays().add(startMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the state of the activity
|
||||
* @param outState Bundle
|
||||
*/
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if(cameraPosition!=null){
|
||||
outState.putParcelable(CAMERA_POS, cameraPosition);
|
||||
}
|
||||
if(activity!=null){
|
||||
outState.putString(ACTIVITY, activity);
|
||||
}
|
||||
|
||||
if(media!=null){
|
||||
outState.putParcelable("sMedia", media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
/**
|
||||
* Constants need for location picking
|
||||
*/
|
||||
public final class LocationPickerConstants {
|
||||
|
||||
public static final String ACTIVITY_KEY
|
||||
= "location.picker.activity";
|
||||
|
||||
public static final String MAP_CAMERA_POSITION
|
||||
= "location.picker.cameraPosition";
|
||||
|
||||
public static final String MEDIA
|
||||
= "location.picker.media";
|
||||
|
||||
|
||||
private LocationPickerConstants() {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
import android.app.Application;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import fr.free.nrw.commons.CameraPosition;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Observes live camera position data
|
||||
*/
|
||||
public class LocationPickerViewModel extends AndroidViewModel implements Callback<CameraPosition> {
|
||||
|
||||
/**
|
||||
* Wrapping CameraPosition with MutableLiveData
|
||||
*/
|
||||
private final MutableLiveData<CameraPosition> result = new MutableLiveData<>();
|
||||
|
||||
/**
|
||||
* Constructor for this class
|
||||
*
|
||||
* @param application Application
|
||||
*/
|
||||
public LocationPickerViewModel(@NonNull final Application application) {
|
||||
super(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Responses on camera position changing
|
||||
*
|
||||
* @param call Call<CameraPosition>
|
||||
* @param response Response<CameraPosition>
|
||||
*/
|
||||
@Override
|
||||
public void onResponse(final @NotNull Call<CameraPosition> call,
|
||||
final Response<CameraPosition> response) {
|
||||
if (response.body() == null) {
|
||||
result.setValue(null);
|
||||
return;
|
||||
}
|
||||
result.setValue(response.body());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(final @NotNull Call<CameraPosition> call, final @NotNull Throwable t) {
|
||||
Timber.e(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets live CameraPosition
|
||||
*
|
||||
* @return MutableLiveData<CameraPosition>
|
||||
*/
|
||||
public MutableLiveData<CameraPosition> getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ class Media constructor(
|
|||
*/
|
||||
var author: String? = null,
|
||||
var user: String? = null,
|
||||
var creatorName: String? = null,
|
||||
/**
|
||||
* Gets the categories the file falls under.
|
||||
* @return file categories as an ArrayList of Strings
|
||||
|
|
@ -66,6 +67,7 @@ class Media constructor(
|
|||
var captions: Map<String, String> = emptyMap(),
|
||||
var descriptions: Map<String, String> = emptyMap(),
|
||||
var depictionIds: List<String> = emptyList(),
|
||||
var creatorIds: List<String> = emptyList(),
|
||||
/**
|
||||
* This field was added to find non-hidden categories
|
||||
* Stores the mapping of category title to hidden attribute
|
||||
|
|
@ -90,6 +92,68 @@ class Media constructor(
|
|||
captions = captions,
|
||||
)
|
||||
|
||||
constructor(
|
||||
captions: Map<String, String>,
|
||||
categories: List<String>?,
|
||||
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<String, String> = emptyMap(),
|
||||
depictionIds: List<String> = emptyList(),
|
||||
categoriesHiddenStatus: Map<String, Boolean> = 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import androidx.core.text.HtmlCompat
|
||||
import fr.free.nrw.commons.media.IdAndCaptions
|
||||
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
|
||||
|
|
@ -29,7 +29,17 @@ class MediaDataExtractor
|
|||
it
|
||||
.entities()
|
||||
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
|
||||
}.map { it.map { (key, value) -> IdAndCaptions(key, 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)
|
||||
|
|
|
|||
|
|
@ -148,13 +148,27 @@ public class Utils {
|
|||
}
|
||||
|
||||
/**
|
||||
* Util function to handle geo coordinates
|
||||
* It no longer depends on google maps and any app capable of handling the map intent can handle it
|
||||
* @param context
|
||||
* @param latLng
|
||||
* Util function to handle geo coordinates. It no longer depends on google maps and any app
|
||||
* capable of handling the map intent can handle it
|
||||
*
|
||||
* @param context The context for launching intent
|
||||
* @param latLng The latitude and longitude of the location
|
||||
*/
|
||||
public static void handleGeoCoordinates(Context context, LatLng latLng) {
|
||||
Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri());
|
||||
public static void handleGeoCoordinates(final Context context, final LatLng latLng) {
|
||||
handleGeoCoordinates(context, latLng, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Util function to handle geo coordinates with specified zoom level. It no longer depends on
|
||||
* google maps and any app capable of handling the map intent can handle it
|
||||
*
|
||||
* @param context The context for launching intent
|
||||
* @param latLng The latitude and longitude of the location
|
||||
* @param zoomLevel The zoom level
|
||||
*/
|
||||
public static void handleGeoCoordinates(final Context context, final LatLng latLng,
|
||||
final double zoomLevel) {
|
||||
final Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri(zoomLevel));
|
||||
if (mapIntent.resolveActivity(context.getPackageManager()) != null) {
|
||||
context.startActivity(mapIntent);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ public class ViewPagerAdapter extends FragmentPagerAdapter {
|
|||
super(manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a ViewPagerAdapter with a specified Fragment Manager and Fragment resume behavior.
|
||||
*
|
||||
* @param manager The FragmentManager
|
||||
* @param behavior An integer which represents the behavior of non visible fragments. See
|
||||
* FragmentPagerAdapter.java for options.
|
||||
*/
|
||||
public ViewPagerAdapter(FragmentManager manager, int behavior) {
|
||||
super(manager, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns the fragment of the viewpager at a particular position
|
||||
* @param position
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ public class WelcomeActivity extends BaseActivity {
|
|||
copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater());
|
||||
final View contactPopupView = copyrightBinding.getRoot();
|
||||
dialogBuilder.setView(contactPopupView);
|
||||
dialogBuilder.setCancelable(false);
|
||||
dialog = dialogBuilder.create();
|
||||
dialog.show();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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.NAMED_COMMONS_CSRF
|
||||
import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF
|
||||
import io.reactivex.Observable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
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<WebView?>(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("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class AccountUtil {
|
||||
|
||||
public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
|
||||
|
||||
public AccountUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Account|null
|
||||
*/
|
||||
@Nullable
|
||||
public static Account account(Context context) {
|
||||
try {
|
||||
Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE);
|
||||
if (accounts.length > 0) {
|
||||
return accounts[0];
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getUserName(Context context) {
|
||||
Account account = account(context);
|
||||
return account == null ? null : account.name;
|
||||
}
|
||||
|
||||
private static AccountManager accountManager(Context context) {
|
||||
return AccountManager.get(context);
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
Normal file
24
app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.accounts.AccountAuthenticatorActivity;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.app.NavUtils;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import fr.free.nrw.commons.auth.login.LoginClient;
|
||||
import fr.free.nrw.commons.auth.login.LoginResult;
|
||||
import fr.free.nrw.commons.databinding.ActivityLoginBinding;
|
||||
import fr.free.nrw.commons.utils.ActivityUtils;
|
||||
import java.util.Locale;
|
||||
import fr.free.nrw.commons.auth.login.LoginCallback;
|
||||
|
||||
import java.util.Objects;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.contributions.MainActivity;
|
||||
import fr.free.nrw.commons.di.ApplicationlessInjection;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import fr.free.nrw.commons.utils.SystemThemeUtils;
|
||||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
import io.reactivex.disposables.CompositeDisposable;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static android.view.KeyEvent.KEYCODE_ENTER;
|
||||
import static android.view.View.VISIBLE;
|
||||
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
|
||||
import static fr.free.nrw.commons.CommonsApplication.LOGIN_MESSAGE_INTENT_KEY;
|
||||
import static fr.free.nrw.commons.CommonsApplication.LOGIN_USERNAME_INTENT_KEY;
|
||||
|
||||
public class LoginActivity extends AccountAuthenticatorActivity {
|
||||
|
||||
@Inject
|
||||
SessionManager sessionManager;
|
||||
|
||||
@Inject
|
||||
@Named("default_preferences")
|
||||
JsonKvStore applicationKvStore;
|
||||
|
||||
@Inject
|
||||
LoginClient loginClient;
|
||||
|
||||
@Inject
|
||||
SystemThemeUtils systemThemeUtils;
|
||||
|
||||
private ActivityLoginBinding binding;
|
||||
ProgressDialog progressDialog;
|
||||
private AppCompatDelegate delegate;
|
||||
private LoginTextWatcher textWatcher = new LoginTextWatcher();
|
||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
||||
final String saveProgressDailog="ProgressDailog_state";
|
||||
final String saveErrorMessage ="errorMessage";
|
||||
final String saveUsername="username";
|
||||
final String savePassword="password";
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
ApplicationlessInjection
|
||||
.getInstance(this.getApplicationContext())
|
||||
.getCommonsApplicationComponent()
|
||||
.inject(this);
|
||||
|
||||
boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode();
|
||||
setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
|
||||
getDelegate().installViewFactory();
|
||||
getDelegate().onCreate(savedInstanceState);
|
||||
|
||||
binding = ActivityLoginBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
String message = getIntent().getStringExtra(LOGIN_MESSAGE_INTENT_KEY);
|
||||
String username = getIntent().getStringExtra(LOGIN_USERNAME_INTENT_KEY);
|
||||
|
||||
binding.loginUsername.addTextChangedListener(textWatcher);
|
||||
binding.loginPassword.addTextChangedListener(textWatcher);
|
||||
binding.loginTwoFactor.addTextChangedListener(textWatcher);
|
||||
|
||||
binding.skipLogin.setOnClickListener(view -> skipLogin());
|
||||
binding.forgotPassword.setOnClickListener(view -> forgotPassword());
|
||||
binding.aboutPrivacyPolicy.setOnClickListener(view -> onPrivacyPolicyClicked());
|
||||
binding.signUpButton.setOnClickListener(view -> signUp());
|
||||
binding.loginButton.setOnClickListener(view -> performLogin());
|
||||
|
||||
binding.loginPassword.setOnEditorActionListener(this::onEditorAction);
|
||||
binding.loginPassword.setOnFocusChangeListener(this::onPasswordFocusChanged);
|
||||
|
||||
if (ConfigUtils.isBetaFlavour()) {
|
||||
binding.loginCredentials.setText(getString(R.string.login_credential));
|
||||
} else {
|
||||
binding.loginCredentials.setVisibility(View.GONE);
|
||||
}
|
||||
if (message != null) {
|
||||
showMessage(message, R.color.secondaryDarkColor);
|
||||
}
|
||||
if (username != null) {
|
||||
binding.loginUsername.setText(username);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hides the keyboard if the user's focus is not on the password (hasFocus is false).
|
||||
* @param view The keyboard
|
||||
* @param hasFocus Set to true if the keyboard has focus
|
||||
*/
|
||||
void onPasswordFocusChanged(View view, boolean hasFocus) {
|
||||
if (!hasFocus) {
|
||||
ViewUtil.hideKeyboard(view);
|
||||
}
|
||||
}
|
||||
|
||||
boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
|
||||
if (binding.loginButton.isEnabled()) {
|
||||
if (actionId == IME_ACTION_DONE) {
|
||||
performLogin();
|
||||
return true;
|
||||
} else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
|
||||
performLogin();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
protected void skipLogin() {
|
||||
new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
|
||||
.setMessage(R.string.skip_login_message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.yes, (dialog, which) -> {
|
||||
dialog.cancel();
|
||||
performSkipLogin();
|
||||
})
|
||||
.setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel())
|
||||
.show();
|
||||
}
|
||||
|
||||
protected void forgotPassword() {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
|
||||
}
|
||||
|
||||
protected void onPrivacyPolicyClicked() {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
|
||||
}
|
||||
|
||||
protected void signUp() {
|
||||
Intent intent = new Intent(this, SignupActivity.class);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostCreate(Bundle savedInstanceState) {
|
||||
super.onPostCreate(savedInstanceState);
|
||||
getDelegate().onPostCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
if (sessionManager.getCurrentAccount() != null
|
||||
&& sessionManager.isUserLoggedIn()) {
|
||||
applicationKvStore.putBoolean("login_skipped", false);
|
||||
startMainActivity();
|
||||
}
|
||||
|
||||
if (applicationKvStore.getBoolean("login_skipped", false)) {
|
||||
performSkipLogin();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
compositeDisposable.clear();
|
||||
try {
|
||||
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
|
||||
if (progressDialog != null && progressDialog.isShowing()) {
|
||||
progressDialog.dismiss();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
binding.loginUsername.removeTextChangedListener(textWatcher);
|
||||
binding.loginPassword.removeTextChangedListener(textWatcher);
|
||||
binding.loginTwoFactor.removeTextChangedListener(textWatcher);
|
||||
delegate.onDestroy();
|
||||
if(null!=loginClient) {
|
||||
loginClient.cancel();
|
||||
}
|
||||
binding = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
public void performLogin() {
|
||||
Timber.d("Login to start!");
|
||||
final String username = Objects.requireNonNull(binding.loginUsername.getText()).toString();
|
||||
final String password = Objects.requireNonNull(binding.loginPassword.getText()).toString();
|
||||
final String twoFactorCode = Objects.requireNonNull(binding.loginTwoFactor.getText()).toString();
|
||||
|
||||
showLoggingProgressBar();
|
||||
loginClient.doLogin(username, password, twoFactorCode, Locale.getDefault().getLanguage(),
|
||||
new LoginCallback() {
|
||||
@Override
|
||||
public void success(@NonNull LoginResult loginResult) {
|
||||
runOnUiThread(()->{
|
||||
Timber.d("Login Success");
|
||||
hideProgress();
|
||||
onLoginSuccess(loginResult);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) {
|
||||
runOnUiThread(()->{
|
||||
Timber.d("Requesting 2FA prompt");
|
||||
hideProgress();
|
||||
askUserForTwoFactorAuth();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void passwordResetPrompt(@Nullable String token) {
|
||||
runOnUiThread(()->{
|
||||
Timber.d("Showing password reset prompt");
|
||||
hideProgress();
|
||||
showPasswordResetPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(@NonNull Throwable caught) {
|
||||
runOnUiThread(()->{
|
||||
Timber.e(caught);
|
||||
hideProgress();
|
||||
showMessageAndCancelDialog(caught.getLocalizedMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void hideProgress() {
|
||||
progressDialog.dismiss();
|
||||
}
|
||||
|
||||
private void showPasswordResetPrompt() {
|
||||
showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function is called when user skips the login.
|
||||
* It redirects the user to Explore Activity.
|
||||
*/
|
||||
private void performSkipLogin() {
|
||||
applicationKvStore.putBoolean("login_skipped", true);
|
||||
MainActivity.startYourself(this);
|
||||
finish();
|
||||
}
|
||||
|
||||
private void showLoggingProgressBar() {
|
||||
progressDialog = new ProgressDialog(this);
|
||||
progressDialog.setIndeterminate(true);
|
||||
progressDialog.setTitle(getString(R.string.logging_in_title));
|
||||
progressDialog.setMessage(getString(R.string.logging_in_message));
|
||||
progressDialog.setCanceledOnTouchOutside(false);
|
||||
progressDialog.show();
|
||||
}
|
||||
|
||||
private void onLoginSuccess(LoginResult loginResult) {
|
||||
compositeDisposable.clear();
|
||||
sessionManager.setUserLoggedIn(true);
|
||||
sessionManager.updateAccount(loginResult);
|
||||
progressDialog.dismiss();
|
||||
showSuccessAndDismissDialog();
|
||||
startMainActivity();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
delegate.onStart();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
delegate.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostResume() {
|
||||
super.onPostResume();
|
||||
getDelegate().onPostResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentView(View view, ViewGroup.LayoutParams params) {
|
||||
getDelegate().setContentView(view, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
NavUtils.navigateUpFromSameTask(this);
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public MenuInflater getMenuInflater() {
|
||||
return getDelegate().getMenuInflater();
|
||||
}
|
||||
|
||||
public void askUserForTwoFactorAuth() {
|
||||
progressDialog.dismiss();
|
||||
binding.twoFactorContainer.setVisibility(VISIBLE);
|
||||
binding.loginTwoFactor.setVisibility(VISIBLE);
|
||||
binding.loginTwoFactor.requestFocus();
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
|
||||
}
|
||||
|
||||
public void showMessageAndCancelDialog(@StringRes int resId) {
|
||||
showMessage(resId, R.color.secondaryDarkColor);
|
||||
if (progressDialog != null) {
|
||||
progressDialog.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void showMessageAndCancelDialog(String error) {
|
||||
showMessage(error, R.color.secondaryDarkColor);
|
||||
if (progressDialog != null) {
|
||||
progressDialog.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void showSuccessAndDismissDialog() {
|
||||
showMessage(R.string.login_success, R.color.primaryDarkColor);
|
||||
progressDialog.dismiss();
|
||||
}
|
||||
|
||||
public void startMainActivity() {
|
||||
ActivityUtils.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
finish();
|
||||
}
|
||||
|
||||
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
|
||||
binding.errorMessage.setText(getString(resId));
|
||||
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
||||
binding.errorMessageContainer.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
private void showMessage(String message, @ColorRes int colorResId) {
|
||||
binding.errorMessage.setText(message);
|
||||
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
||||
binding.errorMessageContainer.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
private AppCompatDelegate getDelegate() {
|
||||
if (delegate == null) {
|
||||
delegate = AppCompatDelegate.create(this, null);
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
private class LoginTextWatcher implements TextWatcher {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
boolean enabled = binding.loginUsername.getText().length() != 0 &&
|
||||
binding.loginPassword.getText().length() != 0 &&
|
||||
(BuildConfig.DEBUG || binding.loginTwoFactor.getText().length() != 0 ||
|
||||
binding.loginTwoFactor.getVisibility() != VISIBLE);
|
||||
binding.loginButton.setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public static void startYourself(Context context) {
|
||||
Intent intent = new Intent(context, LoginActivity.class);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
// if progressDialog is visible during the configuration change then store state as true else false so that
|
||||
// we maintain visibility of progressDailog after configuration change
|
||||
if(progressDialog!=null&&progressDialog.isShowing()) {
|
||||
outState.putBoolean(saveProgressDailog,true);
|
||||
} else {
|
||||
outState.putBoolean(saveProgressDailog,false);
|
||||
}
|
||||
outState.putString(saveErrorMessage,binding.errorMessage.getText().toString()); //Save the errorMessage
|
||||
outState.putString(saveUsername,getUsername()); // Save the username
|
||||
outState.putString(savePassword,getPassword()); // Save the password
|
||||
}
|
||||
private String getUsername() {
|
||||
return binding.loginUsername.getText().toString();
|
||||
}
|
||||
private String getPassword(){
|
||||
return binding.loginPassword.getText().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(final Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
binding.loginUsername.setText(savedInstanceState.getString(saveUsername));
|
||||
binding.loginPassword.setText(savedInstanceState.getString(savePassword));
|
||||
if(savedInstanceState.getBoolean(saveProgressDailog)) {
|
||||
performLogin();
|
||||
}
|
||||
String errorMessage=savedInstanceState.getString(saveErrorMessage);
|
||||
if(sessionManager.isUserLoggedIn()) {
|
||||
showMessage(R.string.login_success, R.color.primaryDarkColor);
|
||||
} else {
|
||||
showMessage(errorMessage, R.color.secondaryDarkColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
415
app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
Normal file
415
app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.accounts.AccountAuthenticatorActivity
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.core.content.ContextCompat
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.CommonsApplication
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.Utils
|
||||
import fr.free.nrw.commons.auth.login.LoginCallback
|
||||
import fr.free.nrw.commons.auth.login.LoginClient
|
||||
import fr.free.nrw.commons.auth.login.LoginResult
|
||||
import fr.free.nrw.commons.contributions.MainActivity
|
||||
import fr.free.nrw.commons.databinding.ActivityLoginBinding
|
||||
import fr.free.nrw.commons.di.ApplicationlessInjection
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import fr.free.nrw.commons.utils.AbstractTextWatcher
|
||||
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||
import fr.free.nrw.commons.utils.SystemThemeUtils
|
||||
import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
class LoginActivity : AccountAuthenticatorActivity() {
|
||||
@Inject
|
||||
lateinit var sessionManager: SessionManager
|
||||
|
||||
@Inject
|
||||
@field:Named("default_preferences")
|
||||
lateinit var applicationKvStore: JsonKvStore
|
||||
|
||||
@Inject
|
||||
lateinit var loginClient: LoginClient
|
||||
|
||||
@Inject
|
||||
lateinit var systemThemeUtils: SystemThemeUtils
|
||||
|
||||
private var binding: ActivityLoginBinding? = null
|
||||
private var progressDialog: ProgressDialog? = null
|
||||
private val textWatcher = AbstractTextWatcher(::onTextChanged)
|
||||
private val compositeDisposable = CompositeDisposable()
|
||||
private val delegate: AppCompatDelegate by lazy {
|
||||
AppCompatDelegate.create(this, null)
|
||||
}
|
||||
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)
|
||||
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
with(binding!!) {
|
||||
setContentView(root)
|
||||
|
||||
loginUsername.addTextChangedListener(textWatcher)
|
||||
loginPassword.addTextChangedListener(textWatcher)
|
||||
loginTwoFactor.addTextChangedListener(textWatcher)
|
||||
|
||||
skipLogin.setOnClickListener { skipLogin() }
|
||||
forgotPassword.setOnClickListener { forgotPassword() }
|
||||
aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
|
||||
signUpButton.setOnClickListener { signUp() }
|
||||
loginButton.setOnClickListener { performLogin() }
|
||||
loginPassword.setOnEditorActionListener(::onEditorAction)
|
||||
|
||||
loginPassword.onFocusChangeListener =
|
||||
View.OnFocusChangeListener(::onPasswordFocusChanged)
|
||||
|
||||
if (isBetaFlavour) {
|
||||
loginCredentials.text = getString(R.string.login_credential)
|
||||
} else {
|
||||
loginCredentials.visibility = View.GONE
|
||||
}
|
||||
|
||||
intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
|
||||
showMessage(it, R.color.secondaryDarkColor)
|
||||
}
|
||||
|
||||
intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
|
||||
loginUsername.setText(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
delegate.onPostCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
|
||||
applicationKvStore.putBoolean("login_skipped", false)
|
||||
startMainActivity()
|
||||
}
|
||||
|
||||
if (applicationKvStore.getBoolean("login_skipped", false)) {
|
||||
performSkipLogin()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
compositeDisposable.clear()
|
||||
try {
|
||||
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
|
||||
if (progressDialog?.isShowing == true) {
|
||||
progressDialog!!.dismiss()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
with(binding!!) {
|
||||
loginUsername.removeTextChangedListener(textWatcher)
|
||||
loginPassword.removeTextChangedListener(textWatcher)
|
||||
loginTwoFactor.removeTextChangedListener(textWatcher)
|
||||
}
|
||||
delegate.onDestroy()
|
||||
loginClient.cancel()
|
||||
binding = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
delegate.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
delegate.onStop()
|
||||
}
|
||||
|
||||
override fun onPostResume() {
|
||||
super.onPostResume()
|
||||
delegate.onPostResume()
|
||||
}
|
||||
|
||||
override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
|
||||
delegate.setContentView(view, params)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
NavUtils.navigateUpFromSameTask(this)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
// if progressDialog is visible during the configuration change then store state as true else false so that
|
||||
// we maintain visibility of progressDialog after configuration change
|
||||
if (progressDialog != null && progressDialog!!.isShowing) {
|
||||
outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
|
||||
} else {
|
||||
outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
|
||||
}
|
||||
outState.putString(
|
||||
SAVE_ERROR_MESSAGE,
|
||||
binding!!.errorMessage.text.toString()
|
||||
) //Save the errorMessage
|
||||
outState.putString(
|
||||
SAVE_USERNAME,
|
||||
binding!!.loginUsername.text.toString()
|
||||
) // Save the username
|
||||
outState.putString(
|
||||
SAVE_PASSWORD,
|
||||
binding!!.loginPassword.text.toString()
|
||||
) // Save the password
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
|
||||
binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
|
||||
if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
|
||||
performLogin()
|
||||
}
|
||||
val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
|
||||
if (sessionManager.isUserLoggedIn) {
|
||||
showMessage(R.string.login_success, R.color.primaryDarkColor)
|
||||
} else {
|
||||
showMessage(errorMessage, R.color.secondaryDarkColor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the keyboard if the user's focus is not on the password (hasFocus is false).
|
||||
* @param view The keyboard
|
||||
* @param hasFocus Set to true if the keyboard has focus
|
||||
*/
|
||||
private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
|
||||
if (!hasFocus) {
|
||||
hideKeyboard(view)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
|
||||
if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
|
||||
performLogin()
|
||||
true
|
||||
} else false
|
||||
|
||||
private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
|
||||
actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
|
||||
|
||||
private fun skipLogin() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.skip_login_title)
|
||||
.setMessage(R.string.skip_login_message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
|
||||
dialog.cancel()
|
||||
performSkipLogin()
|
||||
}
|
||||
.setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
|
||||
dialog.cancel()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun forgotPassword() =
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
|
||||
|
||||
private fun onPrivacyPolicyClicked() =
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
|
||||
|
||||
private fun signUp() =
|
||||
startActivity(Intent(this, SignupActivity::class.java))
|
||||
|
||||
@VisibleForTesting
|
||||
fun performLogin() {
|
||||
Timber.d("Login to start!")
|
||||
val username = binding!!.loginUsername.text.toString()
|
||||
val password = binding!!.loginPassword.text.toString()
|
||||
val twoFactorCode = binding!!.loginTwoFactor.text.toString()
|
||||
|
||||
showLoggingProgressBar()
|
||||
loginClient.doLogin(username,
|
||||
password,
|
||||
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
|
||||
askUserForTwoFactorAuth()
|
||||
}
|
||||
|
||||
override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) {
|
||||
Timber.d("Requesting email auth prompt")
|
||||
progressDialog!!.dismiss()
|
||||
lastLoginResult = loginResult
|
||||
askUserForTwoFactorAuth()
|
||||
}
|
||||
|
||||
override fun passwordResetPrompt(token: String?) = runOnUiThread {
|
||||
Timber.d("Showing password reset prompt")
|
||||
progressDialog!!.dismiss()
|
||||
showPasswordResetPrompt()
|
||||
}
|
||||
|
||||
override fun error(caught: Throwable) = runOnUiThread {
|
||||
Timber.e(caught)
|
||||
progressDialog!!.dismiss()
|
||||
showMessageAndCancelDialog(caught.localizedMessage ?: "")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showPasswordResetPrompt() =
|
||||
showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
|
||||
|
||||
/**
|
||||
* This function is called when user skips the login.
|
||||
* It redirects the user to Explore Activity.
|
||||
*/
|
||||
private fun performSkipLogin() {
|
||||
applicationKvStore.putBoolean("login_skipped", true)
|
||||
MainActivity.startYourself(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun showLoggingProgressBar() {
|
||||
progressDialog = ProgressDialog(this).apply {
|
||||
isIndeterminate = true
|
||||
setTitle(getString(R.string.logging_in_title))
|
||||
setMessage(getString(R.string.logging_in_message))
|
||||
setCancelable(false)
|
||||
}
|
||||
progressDialog!!.show()
|
||||
}
|
||||
|
||||
private fun onLoginSuccess(loginResult: LoginResult) {
|
||||
compositeDisposable.clear()
|
||||
sessionManager.setUserLoggedIn(true)
|
||||
sessionManager.updateAccount(loginResult)
|
||||
progressDialog!!.dismiss()
|
||||
showSuccessAndDismissDialog()
|
||||
startMainActivity()
|
||||
}
|
||||
|
||||
override fun getMenuInflater(): MenuInflater =
|
||||
delegate.menuInflater
|
||||
|
||||
@VisibleForTesting
|
||||
fun askUserForTwoFactorAuth() {
|
||||
progressDialog!!.dismiss()
|
||||
with(binding!!) {
|
||||
twoFactorContainer.visibility = View.VISIBLE
|
||||
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.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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.auth.login.LoginResult;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import io.reactivex.Completable;
|
||||
import io.reactivex.Observable;
|
||||
|
||||
/**
|
||||
* Manage the current logged in user session.
|
||||
*/
|
||||
@Singleton
|
||||
public class SessionManager {
|
||||
private final Context context;
|
||||
private Account currentAccount; // Unlike a savings account... ;-)
|
||||
private JsonKvStore defaultKvStore;
|
||||
|
||||
@Inject
|
||||
public SessionManager(Context context,
|
||||
@Named("default_preferences") JsonKvStore defaultKvStore) {
|
||||
this.context = context;
|
||||
this.currentAccount = null;
|
||||
this.defaultKvStore = defaultKvStore;
|
||||
}
|
||||
|
||||
private boolean createAccount(@NonNull String userName, @NonNull String password) {
|
||||
Account account = getCurrentAccount();
|
||||
if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) {
|
||||
removeAccount();
|
||||
account = new Account(userName, BuildConfig.ACCOUNT_TYPE);
|
||||
return accountManager().addAccountExplicitly(account, password, null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void removeAccount() {
|
||||
Account account = getCurrentAccount();
|
||||
if (account != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
accountManager().removeAccountExplicitly(account);
|
||||
} else {
|
||||
//noinspection deprecation
|
||||
accountManager().removeAccount(account, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void updateAccount(LoginResult result) {
|
||||
boolean accountCreated = createAccount(result.getUserName(), result.getPassword());
|
||||
if (accountCreated) {
|
||||
setPassword(result.getPassword());
|
||||
}
|
||||
}
|
||||
|
||||
private void setPassword(@NonNull String password) {
|
||||
Account account = getCurrentAccount();
|
||||
if (account != null) {
|
||||
accountManager().setPassword(account, password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Account|null
|
||||
*/
|
||||
@Nullable
|
||||
public Account getCurrentAccount() {
|
||||
if (currentAccount == null) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
|
||||
if (allAccounts.length != 0) {
|
||||
currentAccount = allAccounts[0];
|
||||
}
|
||||
}
|
||||
return currentAccount;
|
||||
}
|
||||
|
||||
public boolean doesAccountExist() {
|
||||
return getCurrentAccount() != null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getUserName() {
|
||||
Account account = getCurrentAccount();
|
||||
return account == null ? null : account.name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPassword() {
|
||||
Account account = getCurrentAccount();
|
||||
return account == null ? null : accountManager().getPassword(account);
|
||||
}
|
||||
|
||||
private AccountManager accountManager() {
|
||||
return AccountManager.get(context);
|
||||
}
|
||||
|
||||
public boolean isUserLoggedIn() {
|
||||
return defaultKvStore.getBoolean("isUserLoggedIn", false);
|
||||
}
|
||||
|
||||
void setUserLoggedIn(boolean isLoggedIn) {
|
||||
defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
|
||||
}
|
||||
|
||||
public void forceLogin(Context context) {
|
||||
if (context != null) {
|
||||
LoginActivity.startYourself(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Completable that clears existing accounts from account manager
|
||||
*/
|
||||
public Completable logout() {
|
||||
return Completable.fromObservable(
|
||||
Observable.empty()
|
||||
.doOnComplete(
|
||||
() -> {
|
||||
removeAccount();
|
||||
currentAccount = null;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a corresponding boolean preference
|
||||
*
|
||||
* @param key
|
||||
* @return
|
||||
*/
|
||||
public boolean getPreference(String key) {
|
||||
return defaultKvStore.getBoolean(key);
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
Normal file
95
app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
Normal file
|
|
@ -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<Any>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.Toast;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class SignupActivity extends BaseActivity {
|
||||
|
||||
private WebView webView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Timber.d("Signup Activity started");
|
||||
|
||||
webView = new WebView(this);
|
||||
setContentView(webView);
|
||||
|
||||
webView.setWebViewClient(new MyWebViewClient());
|
||||
WebSettings webSettings = webView.getSettings();
|
||||
/*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
|
||||
trust Wikimedia's site... right?*/
|
||||
webSettings.setJavaScriptEnabled(true);
|
||||
|
||||
webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
|
||||
}
|
||||
|
||||
private class MyWebViewClient extends WebViewClient {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||
if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
|
||||
//Signup success, so clear cookies, notify user, and load LoginActivity again
|
||||
Timber.d("Overriding URL %s", url);
|
||||
|
||||
Toast toast = Toast.makeText(SignupActivity.this,
|
||||
R.string.account_created, Toast.LENGTH_LONG);
|
||||
toast.show();
|
||||
// terminate on task completion.
|
||||
finish();
|
||||
return true;
|
||||
} else {
|
||||
//If user clicks any other links in the webview
|
||||
Timber.d("Not overriding URL, URL is: %s", url);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
|
||||
* https://issuetracker.google.com/issues/141132133
|
||||
* App tries to put light/dark theme to webview and crashes in the process
|
||||
* This code tries to prevent applying the theme when sdk is between api 21 to 25
|
||||
* @param overrideConfiguration
|
||||
*/
|
||||
@Override
|
||||
public void applyOverrideConfiguration(final Configuration overrideConfiguration) {
|
||||
if (Build.VERSION.SDK_INT <= 25 &&
|
||||
(getResources().getConfiguration().uiMode == getApplicationContext().getResources().getConfiguration().uiMode)) {
|
||||
return;
|
||||
}
|
||||
super.applyOverrideConfiguration(overrideConfiguration);
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
Normal file
75
app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.theme.BaseActivity
|
||||
import timber.log.Timber
|
||||
|
||||
class SignupActivity : BaseActivity() {
|
||||
private var webView: WebView? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.d("Signup Activity started")
|
||||
|
||||
webView = WebView(this)
|
||||
with(webView!!) {
|
||||
setContentView(this)
|
||||
webViewClient = MyWebViewClient()
|
||||
// Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
|
||||
// trust Wikimedia's site... right?
|
||||
settings.javaScriptEnabled = true
|
||||
loadUrl(BuildConfig.SIGNUP_LANDING_URL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (webView!!.canGoBack()) {
|
||||
webView!!.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
|
||||
* https://issuetracker.google.com/issues/141132133
|
||||
* App tries to put light/dark theme to webview and crashes in the process
|
||||
* This code tries to prevent applying the theme when sdk is between api 21 to 25
|
||||
*/
|
||||
override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
|
||||
if (Build.VERSION.SDK_INT <= 25 &&
|
||||
(resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
|
||||
) return
|
||||
super.applyOverrideConfiguration(overrideConfiguration)
|
||||
}
|
||||
|
||||
private inner class MyWebViewClient : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
|
||||
if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
|
||||
//Signup success, so clear cookies, notify user, and load LoginActivity again
|
||||
Timber.d("Overriding URL %s", url)
|
||||
|
||||
Toast.makeText(
|
||||
this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
// terminate on task completion.
|
||||
finish()
|
||||
true
|
||||
} else {
|
||||
//If user clicks any other links in the webview
|
||||
Timber.d("Not overriding URL, URL is: %s", url)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountAuthenticatorResponse;
|
||||
import android.accounts.AccountManager;
|
||||
import android.accounts.NetworkErrorException;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
|
||||
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
|
||||
|
||||
/**
|
||||
* Handles WikiMedia commons account Authentication
|
||||
*/
|
||||
public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
|
||||
private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY};
|
||||
|
||||
@NonNull
|
||||
private final Context context;
|
||||
|
||||
public WikiAccountAuthenticator(@NonNull Context context) {
|
||||
super(context);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides Bundle with edited Account Properties
|
||||
*/
|
||||
@Override
|
||||
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("test", "editProperties");
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
|
||||
@NonNull String accountType, @Nullable String authTokenType,
|
||||
@Nullable String[] requiredFeatures, @Nullable Bundle options)
|
||||
throws NetworkErrorException {
|
||||
// account type not supported returns bundle without loginActivity Intent, it just contains "test" key
|
||||
if (!supportedAccountType(accountType)) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("test", "addAccount");
|
||||
return bundle;
|
||||
}
|
||||
|
||||
return addAccount(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
|
||||
@NonNull Account account, @Nullable Bundle options)
|
||||
throws NetworkErrorException {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("test", "confirmCredentials");
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
|
||||
@NonNull Account account, @NonNull String authTokenType,
|
||||
@Nullable Bundle options)
|
||||
throws NetworkErrorException {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("test", "getAuthToken");
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getAuthTokenLabel(@NonNull String authTokenType) {
|
||||
return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
|
||||
@NonNull Account account, @Nullable String authTokenType,
|
||||
@Nullable Bundle options)
|
||||
throws NetworkErrorException {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString("test", "updateCredentials");
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
|
||||
@NonNull Account account, @NonNull String[] features)
|
||||
throws NetworkErrorException {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private boolean supportedAccountType(@Nullable String type) {
|
||||
return BuildConfig.ACCOUNT_TYPE.equals(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a bundle containing a Parcel
|
||||
* the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
|
||||
*/
|
||||
private Bundle addAccount(AccountAuthenticatorResponse response) {
|
||||
Intent intent = new Intent(context, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
|
||||
Account account) throws NetworkErrorException {
|
||||
Bundle result = super.getAccountRemovalAllowed(response, account);
|
||||
|
||||
if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
|
||||
&& !result.containsKey(AccountManager.KEY_INTENT)) {
|
||||
boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
|
||||
|
||||
if (allowed) {
|
||||
for (String auth : SYNC_AUTHORITIES) {
|
||||
ContentResolver.cancelSync(account, auth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>?,
|
||||
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<String>
|
||||
) = 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.di.CommonsDaggerService;
|
||||
|
||||
/**
|
||||
* Handles the Auth service of the App, see AndroidManifests for details
|
||||
* (Uses Dagger 2 as injector)
|
||||
*/
|
||||
public class WikiAccountAuthenticatorService extends CommonsDaggerService {
|
||||
|
||||
@Nullable
|
||||
private AbstractAccountAuthenticator authenticator;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
authenticator = new WikiAccountAuthenticator(this);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return authenticator == null ? null : authenticator.getIBinder();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ class CsrfTokenClient(
|
|||
try {
|
||||
if (retry > 0) {
|
||||
// Log in explicitly
|
||||
loginClient.loginBlocking(userName, password, "")
|
||||
loginClient.loginBlocking(userName, password)
|
||||
}
|
||||
|
||||
// Get CSRFToken response off the main thread.
|
||||
|
|
@ -92,6 +92,8 @@ class CsrfTokenClient(
|
|||
override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught }
|
||||
|
||||
override fun twoFactorPrompt() = cb.twoFactorPrompt()
|
||||
|
||||
override fun emailAuthPrompt() = cb.emailAuthPrompt()
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -165,10 +167,17 @@ class CsrfTokenClient(
|
|||
}
|
||||
|
||||
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."))
|
||||
|
||||
|
|
@ -190,6 +199,8 @@ class CsrfTokenClient(
|
|||
fun failure(caught: Throwable?)
|
||||
|
||||
fun twoFactorPrompt()
|
||||
|
||||
fun emailAuthPrompt()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ interface LoginCallback {
|
|||
fun success(loginResult: LoginResult)
|
||||
|
||||
fun twoFactorPrompt(
|
||||
loginResult: LoginResult,
|
||||
caught: Throwable,
|
||||
token: String?,
|
||||
)
|
||||
|
||||
fun emailAuthPrompt(
|
||||
loginResult: LoginResult,
|
||||
caught: Throwable,
|
||||
token: String?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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
|
||||
|
|
@ -51,6 +52,7 @@ class LoginClient(
|
|||
password,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
response.body()!!.query()!!.loginToken(),
|
||||
userLanguage,
|
||||
cb,
|
||||
|
|
@ -75,6 +77,7 @@ class LoginClient(
|
|||
password: String,
|
||||
retypedPassword: String?,
|
||||
twoFactorCode: String?,
|
||||
emailAuthCode: String?,
|
||||
loginToken: String?,
|
||||
userLanguage: String,
|
||||
cb: LoginCallback,
|
||||
|
|
@ -82,7 +85,7 @@ class LoginClient(
|
|||
this.userLanguage = userLanguage
|
||||
|
||||
loginCall =
|
||||
if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
|
||||
if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
|
||||
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
|
||||
} else {
|
||||
loginInterface.postLogIn(
|
||||
|
|
@ -90,6 +93,7 @@ class LoginClient(
|
|||
password,
|
||||
retypedPassword,
|
||||
twoFactorCode,
|
||||
emailAuthCode,
|
||||
loginToken,
|
||||
userLanguage,
|
||||
true,
|
||||
|
|
@ -112,10 +116,18 @@ class LoginClient(
|
|||
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 ->
|
||||
|
|
@ -147,6 +159,7 @@ class LoginClient(
|
|||
fun doLogin(
|
||||
username: String,
|
||||
password: String,
|
||||
lastLoginResult: LoginResult?,
|
||||
twoFactorCode: String,
|
||||
userLanguage: String,
|
||||
loginCallback: LoginCallback,
|
||||
|
|
@ -159,7 +172,10 @@ class LoginClient(
|
|||
) = if (response.isSuccessful) {
|
||||
val loginToken = response.body()?.query()?.loginToken()
|
||||
loginToken?.let {
|
||||
login(username, password, null, twoFactorCode, it, userLanguage, loginCallback)
|
||||
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"))
|
||||
}
|
||||
|
|
@ -181,7 +197,8 @@ class LoginClient(
|
|||
fun loginBlocking(
|
||||
userName: String,
|
||||
password: String,
|
||||
twoFactorCode: String?,
|
||||
twoFactorCode: String? = null,
|
||||
emailAuthCode: String? = null
|
||||
) {
|
||||
val tokenResponse = getLoginToken().execute()
|
||||
if (tokenResponse
|
||||
|
|
@ -195,7 +212,7 @@ class LoginClient(
|
|||
|
||||
val loginToken = tokenResponse.body()?.query()?.loginToken()
|
||||
val tempLoginCall =
|
||||
if (twoFactorCode.isNullOrEmpty()) {
|
||||
if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) {
|
||||
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
|
||||
} else {
|
||||
loginInterface.postLogIn(
|
||||
|
|
@ -203,6 +220,7 @@ class LoginClient(
|
|||
password,
|
||||
null,
|
||||
twoFactorCode,
|
||||
emailAuthCode,
|
||||
loginToken,
|
||||
userLanguage,
|
||||
true,
|
||||
|
|
@ -214,7 +232,7 @@ class LoginClient(
|
|||
val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.")
|
||||
|
||||
if ("UI" == loginResult.status) {
|
||||
if (loginResult is OAuthResult) {
|
||||
if (loginResult is OAuthResult || loginResult is EmailAuthResult) {
|
||||
// TODO: Find a better way to boil up the warning about 2FA
|
||||
throw LoginFailedException(loginResult.message)
|
||||
}
|
||||
|
|
@ -237,7 +255,7 @@ class LoginClient(
|
|||
.subscribe({ response: MwQueryResponse? ->
|
||||
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
|
||||
loginResult.groups =
|
||||
response?.query()?.getUserResponse(userName)?.groups ?: emptySet()
|
||||
response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
|
||||
cb.success(loginResult)
|
||||
}, { caught: Throwable ->
|
||||
Timber.e(caught, "Login succeeded but getting group information failed. ")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ interface LoginInterface {
|
|||
@Field("password") pass: String?,
|
||||
@Field("retype") retypedPass: String?,
|
||||
@Field("OATHToken") twoFactorCode: String?,
|
||||
@Field("logintoken") token: String?,
|
||||
@Field("token") emailAuthToken: String?,
|
||||
@Field("logintoken") loginToken: String?,
|
||||
@Field("uselang") userLanguage: String?,
|
||||
@Field("logincontinue") loginContinue: Boolean,
|
||||
): Call<LoginResponse?>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -27,11 +28,13 @@ internal class ClientLogin {
|
|||
fun toLoginResult(password: String): LoginResult {
|
||||
var userMessage = message
|
||||
if ("UI" == status) {
|
||||
if (requests != null) {
|
||||
for (req in requests) {
|
||||
if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest" == req.id()) {
|
||||
requests?.forEach { request ->
|
||||
request.id()?.let {
|
||||
if (it.endsWith("TOTPAuthenticationRequest")) {
|
||||
return OAuthResult(status, userName, password, message)
|
||||
} else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest" == req.id()) {
|
||||
} else if (it.endsWith("EmailAuthAuthenticationRequest")) {
|
||||
return EmailAuthResult(status, userName, password, message)
|
||||
} else if (it.endsWith("PasswordAuthenticationRequest")) {
|
||||
return ResetPasswordResult(status, userName, password, message)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +52,7 @@ internal class Request {
|
|||
private val required: String? = null
|
||||
private val provider: String? = null
|
||||
private val account: String? = null
|
||||
private val fields: Map<String, RequestField>? = null
|
||||
internal val fields: Map<String, RequestField>? = null
|
||||
|
||||
fun id(): String? = id
|
||||
}
|
||||
|
|
@ -57,5 +60,5 @@ internal class Request {
|
|||
internal class RequestField {
|
||||
private val type: String? = null
|
||||
private val label: String? = null
|
||||
private val help: String? = null
|
||||
internal val help: String? = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ sealed class LoginResult(
|
|||
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?,
|
||||
|
|
|
|||
|
|
@ -2,18 +2,17 @@ package fr.free.nrw.commons.bookmarks;
|
|||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import 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;
|
||||
|
|
@ -26,6 +25,7 @@ import fr.free.nrw.commons.media.MediaDetailPagerFragment;
|
|||
import fr.free.nrw.commons.navtab.NavTab;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements
|
||||
FragmentManager.OnBackStackChangedListener,
|
||||
|
|
@ -48,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
String title = bundle.getString("categoryName");
|
||||
int order = bundle.getInt("order");
|
||||
final int orderItem = bundle.getInt("orderItem");
|
||||
if (order == 0) {
|
||||
listFragment = new BookmarkPicturesFragment();
|
||||
} else {
|
||||
listFragment = new BookmarkLocationsFragment();
|
||||
|
||||
switch (order){
|
||||
case 0: listFragment = new BookmarkPicturesFragment();
|
||||
break;
|
||||
|
||||
case 1: listFragment = new BookmarkLocationsFragment();
|
||||
break;
|
||||
|
||||
case 3: listFragment = new BookmarkCategoriesFragment();
|
||||
break;
|
||||
}
|
||||
if(orderItem == 2) {
|
||||
listFragment = new BookmarkItemsFragment();
|
||||
}
|
||||
}
|
||||
|
||||
Bundle featuredArguments = new Bundle();
|
||||
featuredArguments.putString("categoryName", title);
|
||||
listFragment.setArguments(featuredArguments);
|
||||
|
|
@ -129,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
|
||||
@Override
|
||||
public void onMediaClicked(int position) {
|
||||
Log.d("deneme8", "on media clicked");
|
||||
Timber.d("on media clicked");
|
||||
/*container.setVisibility(View.VISIBLE);
|
||||
((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE);
|
||||
mediaDetails = new MediaDetailPagerFragment(false, true, position);
|
||||
|
|
@ -237,7 +244,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
|
||||
Log.d("deneme8", "on media clicked");
|
||||
Timber.d("on media clicked");
|
||||
binding.exploreContainer.setVisibility(View.VISIBLE);
|
||||
((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
|
||||
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks;
|
||||
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
/**
|
||||
* Data class for handling a bookmark fragment and it title
|
||||
*/
|
||||
public class BookmarkPages {
|
||||
private Fragment page;
|
||||
private String title;
|
||||
|
||||
BookmarkPages(Fragment fragment, String title) {
|
||||
this.title = title;
|
||||
this.page = fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fragment
|
||||
* @return fragment object
|
||||
*/
|
||||
public Fragment getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fragment title
|
||||
* @return title
|
||||
*/
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -49,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter {
|
|||
new BookmarkListRootFragment(locationBundle, this),
|
||||
context.getString(R.string.title_page_bookmarks_items)));
|
||||
}
|
||||
final Bundle categoriesBundle = new Bundle();
|
||||
categoriesBundle.putString("categoryName",
|
||||
context.getString(R.string.title_page_bookmarks_categories));
|
||||
categoriesBundle.putInt("order", 3);
|
||||
pages.add(new BookmarkPages(
|
||||
new BookmarkListRootFragment(categoriesBundle, this),
|
||||
context.getString(R.string.title_page_bookmarks_categories)));
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<List<BookmarksCategoryModal>>
|
||||
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks.locations;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteQueryBuilder;
|
||||
// We can get uri using java.Net.Uri, but andoid implimentation is faster (but it's forgiving with handling exceptions though)
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.data.DBOpenHelper;
|
||||
import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.COLUMN_NAME;
|
||||
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.TABLE_NAME;
|
||||
|
||||
/**
|
||||
* Handles private storage for Bookmark locations
|
||||
*/
|
||||
public class BookmarkLocationsContentProvider extends CommonsDaggerContentProvider {
|
||||
|
||||
private static final String BASE_PATH = "bookmarksLocations";
|
||||
public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY + "/" + BASE_PATH);
|
||||
|
||||
/**
|
||||
* Append bookmark locations name to the base uri
|
||||
*/
|
||||
public static Uri uriForName(String name) {
|
||||
return Uri.parse(BASE_URI.toString() + "/" + name);
|
||||
}
|
||||
|
||||
@Inject DBOpenHelper dbOpenHelper;
|
||||
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the SQLite database for the bookmark locations
|
||||
* @param uri : contains the uri for bookmark locations
|
||||
* @param projection
|
||||
* @param selection : handles Where
|
||||
* @param selectionArgs : the condition of Where clause
|
||||
* @param sortOrder : ascending or descending
|
||||
*/
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder) {
|
||||
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
|
||||
queryBuilder.setTables(TABLE_NAME);
|
||||
|
||||
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
|
||||
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
|
||||
cursor.setNotificationUri(getContext().getContentResolver(), uri);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the update query of local SQLite Database
|
||||
* @param uri : contains the uri for bookmark locations
|
||||
* @param contentValues : new values to be entered to db
|
||||
* @param selection : handles Where
|
||||
* @param selectionArgs : the condition of Where clause
|
||||
*/
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
|
||||
String[] selectionArgs) {
|
||||
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
|
||||
int rowsUpdated;
|
||||
if (TextUtils.isEmpty(selection)) {
|
||||
int id = Integer.valueOf(uri.getLastPathSegment());
|
||||
rowsUpdated = sqlDB.update(TABLE_NAME,
|
||||
contentValues,
|
||||
COLUMN_NAME + " = ?",
|
||||
new String[]{String.valueOf(id)});
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
"Parameter `selection` should be empty when updating an ID");
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return rowsUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the insertion of new bookmark locations record to local SQLite Database
|
||||
*/
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
|
||||
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
|
||||
long id = sqlDB.insert(BookmarkLocationsDao.Table.TABLE_NAME, null, contentValues);
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return Uri.parse(BASE_URI + "/" + id);
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, String s, String[] strings) {
|
||||
int rows;
|
||||
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
|
||||
Timber.d("Deleting bookmark name %s", uri.getLastPathSegment());
|
||||
rows = db.delete(TABLE_NAME,
|
||||
"location_name = ?",
|
||||
new String[]{uri.getLastPathSegment()}
|
||||
);
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks.locations;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
|
||||
@Singleton
|
||||
public class BookmarkLocationsController {
|
||||
|
||||
@Inject
|
||||
BookmarkLocationsDao bookmarkLocationDao;
|
||||
|
||||
@Inject
|
||||
public BookmarkLocationsController() {}
|
||||
|
||||
/**
|
||||
* Load from DB the bookmarked locations
|
||||
* @return a list of Place objects.
|
||||
*/
|
||||
public List<Place> loadFavoritesLocations() {
|
||||
return bookmarkLocationDao.getAllBookmarksLocations();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Place> =
|
||||
bookmarkLocationDao.getAllBookmarksLocationsPlace()
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks.locations;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import fr.free.nrw.commons.nearby.NearbyController;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Provider;
|
||||
|
||||
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;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider.BASE_URI;
|
||||
|
||||
public class BookmarkLocationsDao {
|
||||
|
||||
private final Provider<ContentProviderClient> clientProvider;
|
||||
|
||||
@Inject
|
||||
public BookmarkLocationsDao(@Named("bookmarksLocation") Provider<ContentProviderClient> clientProvider) {
|
||||
this.clientProvider = clientProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all persisted locations bookmarks on database
|
||||
*
|
||||
* @return list of Place
|
||||
*/
|
||||
@NonNull
|
||||
public List<Place> getAllBookmarksLocations() {
|
||||
List<Place> items = new ArrayList<>();
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
items.add(fromCursor(cursor));
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
db.release();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for a place in bookmarks table in order to insert or delete it
|
||||
*
|
||||
* @param bookmarkLocation : Place object
|
||||
* @return is Place now fav ?
|
||||
*/
|
||||
public boolean updateBookmarkLocation(Place bookmarkLocation) {
|
||||
boolean bookmarkExists = findBookmarkLocation(bookmarkLocation);
|
||||
if (bookmarkExists) {
|
||||
deleteBookmarkLocation(bookmarkLocation);
|
||||
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, false);
|
||||
} else {
|
||||
addBookmarkLocation(bookmarkLocation);
|
||||
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, true);
|
||||
}
|
||||
return !bookmarkExists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Place to bookmarks table
|
||||
*
|
||||
* @param bookmarkLocation : Place to add
|
||||
*/
|
||||
private void addBookmarkLocation(Place bookmarkLocation) {
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
db.insert(BASE_URI, toContentValues(bookmarkLocation));
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Place from bookmarks table
|
||||
*
|
||||
* @param bookmarkLocation : Place to delete
|
||||
*/
|
||||
private void deleteBookmarkLocation(Place bookmarkLocation) {
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
db.delete(BookmarkLocationsContentProvider.uriForName(bookmarkLocation.name), null, null);
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Place from database based on its name
|
||||
*
|
||||
* @param bookmarkLocation : Place to find
|
||||
* @return boolean : is Place in database ?
|
||||
*/
|
||||
public boolean findBookmarkLocation(Place bookmarkLocation) {
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{bookmarkLocation.name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return true;
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
// This feels lazy, but to hell with checked exceptions. :)
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
db.release();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
@NonNull
|
||||
Place fromCursor(final Cursor cursor) {
|
||||
final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)),
|
||||
cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
|
||||
|
||||
final Sitelinks.Builder builder = new Sitelinks.Builder();
|
||||
builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK)));
|
||||
builder.setWikidataLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIDATA_LINK)));
|
||||
builder.setCommonsLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_COMMONS_LINK)));
|
||||
|
||||
return new Place(
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_LANGUAGE)),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
|
||||
Label.fromText((cursor.getString(cursor.getColumnIndex(Table.COLUMN_LABEL_TEXT)))),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
|
||||
location,
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_CATEGORY)),
|
||||
builder.build(),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_PIC)),
|
||||
Boolean.parseBoolean(cursor.getString(cursor.getColumnIndex(Table.COLUMN_EXISTS)))
|
||||
);
|
||||
}
|
||||
|
||||
private ContentValues toContentValues(Place bookmarkLocation) {
|
||||
ContentValues cv = new ContentValues();
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_NAME, bookmarkLocation.getName());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LANGUAGE, bookmarkLocation.getLanguage());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_DESCRIPTION, bookmarkLocation.getLongDescription());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_CATEGORY, bookmarkLocation.getCategory());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getText() : "");
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getIcon() : null);
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIPEDIA_LINK, bookmarkLocation.siteLinks.getWikipediaLink().toString());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIDATA_LINK, bookmarkLocation.siteLinks.getWikidataLink().toString());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_COMMONS_LINK, bookmarkLocation.siteLinks.getCommonsLink().toString());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LAT, bookmarkLocation.location.getLatitude());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LONG, bookmarkLocation.location.getLongitude());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_PIC, bookmarkLocation.pic);
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_EXISTS, bookmarkLocation.exists.toString());
|
||||
return cv;
|
||||
}
|
||||
|
||||
public static class Table {
|
||||
public static final String TABLE_NAME = "bookmarksLocations";
|
||||
|
||||
static final String COLUMN_NAME = "location_name";
|
||||
static final String COLUMN_LANGUAGE = "location_language";
|
||||
static final String COLUMN_DESCRIPTION = "location_description";
|
||||
static final String COLUMN_LAT = "location_lat";
|
||||
static final String COLUMN_LONG = "location_long";
|
||||
static final String COLUMN_CATEGORY = "location_category";
|
||||
static final String COLUMN_LABEL_TEXT = "location_label_text";
|
||||
static final String COLUMN_LABEL_ICON = "location_label_icon";
|
||||
static final String COLUMN_IMAGE_URL = "location_image_url";
|
||||
static final String COLUMN_WIKIPEDIA_LINK = "location_wikipedia_link";
|
||||
static final String COLUMN_WIKIDATA_LINK = "location_wikidata_link";
|
||||
static final String COLUMN_COMMONS_LINK = "location_commons_link";
|
||||
static final String COLUMN_PIC = "location_pic";
|
||||
static final String COLUMN_EXISTS = "location_exists";
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
public static final String[] ALL_FIELDS = {
|
||||
COLUMN_NAME,
|
||||
COLUMN_LANGUAGE,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_CATEGORY,
|
||||
COLUMN_LABEL_TEXT,
|
||||
COLUMN_LABEL_ICON,
|
||||
COLUMN_LAT,
|
||||
COLUMN_LONG,
|
||||
COLUMN_IMAGE_URL,
|
||||
COLUMN_WIKIPEDIA_LINK,
|
||||
COLUMN_WIKIDATA_LINK,
|
||||
COLUMN_COMMONS_LINK,
|
||||
COLUMN_PIC,
|
||||
COLUMN_EXISTS,
|
||||
};
|
||||
|
||||
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
|
||||
|
||||
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
|
||||
+ COLUMN_NAME + " STRING PRIMARY KEY,"
|
||||
+ COLUMN_LANGUAGE + " STRING,"
|
||||
+ COLUMN_DESCRIPTION + " STRING,"
|
||||
+ COLUMN_CATEGORY + " STRING,"
|
||||
+ COLUMN_LABEL_TEXT + " STRING,"
|
||||
+ COLUMN_LABEL_ICON + " INTEGER,"
|
||||
+ COLUMN_LAT + " DOUBLE,"
|
||||
+ COLUMN_LONG + " DOUBLE,"
|
||||
+ COLUMN_IMAGE_URL + " STRING,"
|
||||
+ COLUMN_WIKIPEDIA_LINK + " STRING,"
|
||||
+ COLUMN_WIKIDATA_LINK + " STRING,"
|
||||
+ COLUMN_COMMONS_LINK + " STRING,"
|
||||
+ COLUMN_PIC + " STRING,"
|
||||
+ COLUMN_EXISTS + " STRING"
|
||||
+ ");";
|
||||
|
||||
public static void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT);
|
||||
}
|
||||
|
||||
public static void onDelete(SQLiteDatabase db) {
|
||||
db.execSQL(DROP_TABLE_STATEMENT);
|
||||
onCreate(db);
|
||||
}
|
||||
|
||||
public static void onUpdate(final SQLiteDatabase db, int from, final int to) {
|
||||
Timber.d("bookmarksLocations db is updated from:"+from+", to:"+to);
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (from < 7) {
|
||||
// doesn't exist yet
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 7) {
|
||||
// table added in version 8
|
||||
onCreate(db);
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from < 10) {
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 10) {
|
||||
//This is safe, and can be called clean, as we/I do not remember the appropriate version for this
|
||||
//We are anyways switching to room, these things won't be necessary then
|
||||
try {
|
||||
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_pic STRING;");
|
||||
}catch (SQLiteException exception){
|
||||
Timber.e(exception);//
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (from >= 12) {
|
||||
try {
|
||||
db.execSQL(
|
||||
"ALTER TABLE bookmarksLocations ADD COLUMN location_destroyed STRING;");
|
||||
} catch (SQLiteException exception) {
|
||||
Timber.e(exception);
|
||||
}
|
||||
}
|
||||
if (from >= 13){
|
||||
try {
|
||||
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_language STRING;");
|
||||
} catch (SQLiteException exception){
|
||||
Timber.e(exception);
|
||||
}
|
||||
}
|
||||
if (from >= 14){
|
||||
try {
|
||||
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_exists STRING;");
|
||||
} catch (SQLiteException exception){
|
||||
Timber.e(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BookmarksLocations>
|
||||
|
||||
/**
|
||||
* 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<Place> {
|
||||
return getAllBookmarksLocations().map { it.toPlace() }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks.locations;
|
||||
|
||||
import android.Manifest.permission;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import androidx.activity.result.ActivityResultCallback;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import dagger.android.support.DaggerFragment;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.contributions.ContributionController;
|
||||
import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding;
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions;
|
||||
import fr.free.nrw.commons.nearby.fragments.PlaceAdapter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.inject.Inject;
|
||||
import kotlin.Unit;
|
||||
|
||||
public class BookmarkLocationsFragment extends DaggerFragment {
|
||||
|
||||
public FragmentBookmarksLocationsBinding binding;
|
||||
|
||||
@Inject BookmarkLocationsController controller;
|
||||
@Inject ContributionController contributionController;
|
||||
@Inject BookmarkLocationsDao bookmarkLocationDao;
|
||||
@Inject CommonPlaceClickActions commonPlaceClickActions;
|
||||
private PlaceAdapter adapter;
|
||||
|
||||
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
|
||||
registerForActivityResult(new StartActivityForResult(),
|
||||
result -> {
|
||||
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
|
||||
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
|
||||
});
|
||||
});
|
||||
|
||||
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
|
||||
registerForActivityResult(new StartActivityForResult(),
|
||||
result -> {
|
||||
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
|
||||
contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
|
||||
});
|
||||
});
|
||||
|
||||
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() {
|
||||
@Override
|
||||
public void onActivityResult(Map<String, Boolean> result) {
|
||||
boolean areAllGranted = true;
|
||||
for(final boolean b : result.values()) {
|
||||
areAllGranted = areAllGranted && b;
|
||||
}
|
||||
|
||||
if (areAllGranted) {
|
||||
contributionController.locationPermissionCallback.onLocationPermissionGranted();
|
||||
} else {
|
||||
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
|
||||
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
|
||||
} else {
|
||||
contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an instance of the fragment with the right bundle parameters
|
||||
* @return an instance of the fragment
|
||||
*/
|
||||
public static BookmarkLocationsFragment newInstance() {
|
||||
return new BookmarkLocationsFragment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull LayoutInflater inflater,
|
||||
ViewGroup container,
|
||||
Bundle savedInstanceState
|
||||
) {
|
||||
binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
binding.loadingImagesProgressBar.setVisibility(View.VISIBLE);
|
||||
binding.listView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
adapter = new PlaceAdapter(bookmarkLocationDao,
|
||||
place -> Unit.INSTANCE,
|
||||
(place, isBookmarked) -> {
|
||||
adapter.remove(place);
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
commonPlaceClickActions,
|
||||
inAppCameraLocationPermissionLauncher,
|
||||
galleryPickLauncherForResult,
|
||||
cameraPickLauncherForResult
|
||||
);
|
||||
binding.listView.setAdapter(adapter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
initList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the recycler view with bookmarked locations
|
||||
*/
|
||||
private void initList() {
|
||||
List<Place> places = controller.loadFavoritesLocations();
|
||||
adapter.setItems(places);
|
||||
binding.loadingImagesProgressBar.setVisibility(View.GONE);
|
||||
if (places.size() <= 0) {
|
||||
binding.statusMessage.setText(R.string.bookmark_empty);
|
||||
binding.statusMessage.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.statusMessage.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
binding = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Array<String>>
|
||||
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<Place>
|
||||
if(view != null) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
places = controller.loadFavoritesLocations()
|
||||
updateUIList(places)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUIList(places: List<Place>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Place> {
|
||||
// return bookmarkLocationsDao.getAllBookmarksLocationsPlace()
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign;
|
||||
import fr.free.nrw.commons.databinding.LayoutCampaginBinding;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import fr.free.nrw.commons.utils.CommonsDateUtil;
|
||||
|
||||
import fr.free.nrw.commons.utils.DateUtil;
|
||||
import java.text.ParseException;
|
||||
import java.util.Date;
|
||||
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.contributions.MainActivity;
|
||||
import fr.free.nrw.commons.utils.SwipableCardView;
|
||||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
|
||||
/**
|
||||
* A view which represents a single campaign
|
||||
*/
|
||||
public class CampaignView extends SwipableCardView {
|
||||
Campaign campaign;
|
||||
private LayoutCampaginBinding binding;
|
||||
private ViewHolder viewHolder;
|
||||
|
||||
public static final String CAMPAIGNS_DEFAULT_PREFERENCE = "displayCampaignsCardView";
|
||||
public static final String WLM_CARD_PREFERENCE = "displayWLMCardView";
|
||||
|
||||
private String campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE;
|
||||
|
||||
public CampaignView(@NonNull Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
public void setCampaign(final Campaign campaign) {
|
||||
this.campaign = campaign;
|
||||
if (campaign != null) {
|
||||
if (campaign.isWLMCampaign()) {
|
||||
campaignPreference = WLM_CARD_PREFERENCE;
|
||||
}
|
||||
setVisibility(View.VISIBLE);
|
||||
viewHolder.init();
|
||||
} else {
|
||||
this.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean onSwipe(final View view) {
|
||||
view.setVisibility(View.GONE);
|
||||
((BaseActivity) getContext()).defaultKvStore
|
||||
.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false);
|
||||
ViewUtil.showLongToast(getContext(),
|
||||
getResources().getString(R.string.nearby_campaign_dismiss_message));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void init() {
|
||||
binding = LayoutCampaginBinding.inflate(LayoutInflater.from(getContext()), this, true);
|
||||
viewHolder = new ViewHolder();
|
||||
setOnClickListener(view -> {
|
||||
if (campaign != null) {
|
||||
if (campaign.isWLMCampaign()) {
|
||||
((MainActivity)(getContext())).showNearby();
|
||||
} else {
|
||||
Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public class ViewHolder {
|
||||
public void init() {
|
||||
if (campaign != null) {
|
||||
binding.ivCampaign.setImageDrawable(
|
||||
getResources().getDrawable(R.drawable.ic_campaign));
|
||||
|
||||
binding.tvTitle.setText(campaign.getTitle());
|
||||
binding.tvDescription.setText(campaign.getDescription());
|
||||
try {
|
||||
if (campaign.isWLMCampaign()) {
|
||||
binding.tvDates.setText(
|
||||
String.format("%1s - %2s", campaign.getStartDate(),
|
||||
campaign.getEndDate()));
|
||||
} else {
|
||||
final Date startDate = CommonsDateUtil.getIso8601DateFormatShort()
|
||||
.parse(campaign.getStartDate());
|
||||
final Date endDate = CommonsDateUtil.getIso8601DateFormatShort()
|
||||
.parse(campaign.getEndDate());
|
||||
binding.tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
|
||||
DateUtil.getExtraShortDateString(endDate)));
|
||||
}
|
||||
} catch (final ParseException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
Normal file
121
app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
Normal file
|
|
@ -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.Utils
|
||||
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 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 {
|
||||
Utils.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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign;
|
||||
import fr.free.nrw.commons.utils.CommonsDateUtil;
|
||||
import java.text.ParseException;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import fr.free.nrw.commons.BasePresenter;
|
||||
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
|
||||
import io.reactivex.Scheduler;
|
||||
import io.reactivex.Single;
|
||||
import io.reactivex.SingleObserver;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
|
||||
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
|
||||
|
||||
/**
|
||||
* The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
|
||||
* success and error
|
||||
*/
|
||||
@Singleton
|
||||
public class CampaignsPresenter implements BasePresenter<ICampaignsView> {
|
||||
private final OkHttpJsonApiClient okHttpJsonApiClient;
|
||||
private final Scheduler mainThreadScheduler;
|
||||
private final Scheduler ioScheduler;
|
||||
|
||||
private ICampaignsView view;
|
||||
private Disposable disposable;
|
||||
private Campaign campaign;
|
||||
|
||||
@Inject
|
||||
public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) {
|
||||
this.okHttpJsonApiClient = okHttpJsonApiClient;
|
||||
this.mainThreadScheduler=mainThreadScheduler;
|
||||
this.ioScheduler=ioScheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttachView(ICampaignsView view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
@Override public void onDetachView() {
|
||||
this.view = null;
|
||||
if (disposable != null) {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* make the api call to fetch the campaigns
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
public void getCampaigns() {
|
||||
if (view != null && okHttpJsonApiClient != null) {
|
||||
//If we already have a campaign, lets not make another call
|
||||
if (this.campaign != null) {
|
||||
view.showCampaigns(campaign);
|
||||
return;
|
||||
}
|
||||
Single<CampaignResponseDTO> campaigns = okHttpJsonApiClient.getCampaigns();
|
||||
campaigns.observeOn(mainThreadScheduler)
|
||||
.subscribeOn(ioScheduler)
|
||||
.subscribeWith(new SingleObserver<CampaignResponseDTO>() {
|
||||
|
||||
@Override public void onSubscribe(Disposable d) {
|
||||
disposable = d;
|
||||
}
|
||||
|
||||
@Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) {
|
||||
List<Campaign> campaigns = campaignResponseDTO.getCampaigns();
|
||||
if (campaigns == null || campaigns.isEmpty()) {
|
||||
Timber.e("The campaigns list is empty");
|
||||
view.showCampaigns(null);
|
||||
return;
|
||||
}
|
||||
Collections.sort(campaigns, (campaign, t1) -> {
|
||||
Date date1, date2;
|
||||
try {
|
||||
|
||||
date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate());
|
||||
date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate());
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
return -1;
|
||||
}
|
||||
return date1.compareTo(date2);
|
||||
});
|
||||
Date campaignEndDate, campaignStartDate;
|
||||
Date currentDate = new Date();
|
||||
try {
|
||||
for (Campaign aCampaign : campaigns) {
|
||||
campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate());
|
||||
campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate());
|
||||
if (campaignEndDate.compareTo(currentDate) >= 0
|
||||
&& campaignStartDate.compareTo(currentDate) <= 0) {
|
||||
campaign = aCampaign;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
view.showCampaigns(campaign);
|
||||
}
|
||||
|
||||
@Override public void onError(Throwable e) {
|
||||
Timber.e(e, "could not fetch campaigns");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ICampaignsView?> {
|
||||
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<Campaign>) {
|
||||
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>) : 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import fr.free.nrw.commons.MvpView;
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign;
|
||||
|
||||
/**
|
||||
* Interface which defines the view contracts of the campaign view
|
||||
*/
|
||||
public interface ICampaignsView extends MvpView {
|
||||
void showCampaigns(Campaign campaign);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package fr.free.nrw.commons.campaigns
|
||||
|
||||
import fr.free.nrw.commons.MvpView
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign
|
||||
|
||||
/**
|
||||
* Interface which defines the view contracts of the campaign view
|
||||
*/
|
||||
interface ICampaignsView : MvpView {
|
||||
fun showCampaigns(campaign: Campaign?)
|
||||
}
|
||||
|
|
@ -36,37 +36,35 @@ class CategoriesModel
|
|||
* @return
|
||||
*/
|
||||
fun isSpammyCategory(item: String): Boolean {
|
||||
// Check for current and previous year to exclude these categories from removal
|
||||
val now = Calendar.getInstance()
|
||||
val curYear = now[Calendar.YEAR]
|
||||
val curYearInString = curYear.toString()
|
||||
val prevYear = curYear - 1
|
||||
val prevYearInString = prevYear.toString()
|
||||
Timber.d("Previous year: %s", prevYearInString)
|
||||
|
||||
val mentionsDecade = item.matches(".*0s.*".toRegex())
|
||||
val recentDecade = item.matches(".*20[0-2]0s.*".toRegex())
|
||||
val spammyCategory =
|
||||
item.matches("(.*)needing(.*)".toRegex()) ||
|
||||
item.matches("(.*)taken on(.*)".toRegex())
|
||||
|
||||
// always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
|
||||
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 (mentionsDecade) {
|
||||
// Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029
|
||||
// Example: "2020s" is OK, but "1920s" is not (and should be skipped)
|
||||
return !recentDecade
|
||||
} else {
|
||||
// If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year
|
||||
// anywhere within the string (.* is wildcard) (Issue #47)
|
||||
// And that item does not equal the current year or previous year
|
||||
return item.matches(".*(19|20)\\d{2}.*".toRegex()) &&
|
||||
!item.contains(curYearInString) &&
|
||||
!item.contains(prevYearInString)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,7 +76,13 @@ class CategoriesModel
|
|||
|
||||
// Newly used category...
|
||||
if (category == null) {
|
||||
category = Category(null, item.name, item.description, item.thumbnail, Date(), 0)
|
||||
category = Category(
|
||||
null, item.name,
|
||||
item.description,
|
||||
item.thumbnail,
|
||||
Date(),
|
||||
0
|
||||
)
|
||||
}
|
||||
category.incTimesUsed()
|
||||
categoryDao.save(category)
|
||||
|
|
@ -121,14 +125,41 @@ class CategoriesModel
|
|||
/**
|
||||
* 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<DepictedItem>): Observable<MutableList<CategoryItem>>? =
|
||||
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? {
|
||||
val observables = selectedDepictions.map { depictedItem ->
|
||||
if (depictedItem.commonsCategories.isEmpty()) {
|
||||
if (depictedItem.primaryImage == null) {
|
||||
return@map Observable.just(emptyList<CategoryItem>())
|
||||
}
|
||||
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(
|
||||
selectedDepictions.map { it.commonsCategories }.flatten(),
|
||||
depictedItem.commonsCategories,
|
||||
).map { categoryItem ->
|
||||
categoryClient
|
||||
.getCategoriesByName(
|
||||
|
|
@ -145,6 +176,13 @@ class CategoriesModel
|
|||
}.blockingGet()
|
||||
}.toList()
|
||||
.toObservable()
|
||||
}
|
||||
}
|
||||
return Observable.concat(observables)
|
||||
.scan(mutableListOf<CategoryItem>()) { accumulator, currentList ->
|
||||
accumulator.apply { addAll(currentList) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches details of every category by their name, converts them into
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Represents a category
|
||||
*/
|
||||
public class Category {
|
||||
private Uri contentUri;
|
||||
private String name;
|
||||
private String description;
|
||||
private String thumbnail;
|
||||
private Date lastUsed;
|
||||
private int timesUsed;
|
||||
|
||||
public Category() {
|
||||
}
|
||||
|
||||
public Category(Uri contentUri, String name, String description, String thumbnail, Date lastUsed, int timesUsed) {
|
||||
this.contentUri = contentUri;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.thumbnail = thumbnail;
|
||||
this.lastUsed = lastUsed;
|
||||
this.timesUsed = timesUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets name
|
||||
*
|
||||
* @return name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies name
|
||||
*
|
||||
* @param name Category name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets last used date
|
||||
*
|
||||
* @return Last used date
|
||||
*/
|
||||
public Date getLastUsed() {
|
||||
// warning: Date objects are mutable.
|
||||
return (Date)lastUsed.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates new last used date
|
||||
*/
|
||||
private void touch() {
|
||||
lastUsed = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets no. of times the category is used
|
||||
*
|
||||
* @return no. of times used
|
||||
*/
|
||||
public int getTimesUsed() {
|
||||
return timesUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments timesUsed by 1 and sets last used date as now.
|
||||
*/
|
||||
public void incTimesUsed() {
|
||||
timesUsed++;
|
||||
touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content URI for this category
|
||||
*
|
||||
* @return content URI
|
||||
*/
|
||||
public Uri getContentUri() {
|
||||
return contentUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the content URI - marking this category as already saved in the database
|
||||
*
|
||||
* @param contentUri the content URI
|
||||
*/
|
||||
public void setContentUri(Uri contentUri) {
|
||||
this.contentUri = contentUri;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public String getThumbnail() {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
public void setDescription(final String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public void setThumbnail(final String thumbnail) {
|
||||
this.thumbnail = thumbnail;
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/fr/free/nrw/commons/category/Category.kt
Normal file
17
app/src/main/java/fr/free/nrw/commons/category/Category.kt
Normal file
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
public interface CategoryClickedListener {
|
||||
void categoryClicked(CategoryItem item);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
interface CategoryClickedListener {
|
||||
fun categoryClicked(item: CategoryItem)
|
||||
}
|
||||
|
|
@ -78,6 +78,24 @@ class CategoryClient
|
|||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<List<CategoryItem>> =
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.UriMatcher;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteQueryBuilder;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.data.DBOpenHelper;
|
||||
import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static android.content.UriMatcher.NO_MATCH;
|
||||
import static fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS;
|
||||
import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID;
|
||||
import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME;
|
||||
|
||||
public class CategoryContentProvider extends CommonsDaggerContentProvider {
|
||||
|
||||
// For URI matcher
|
||||
private static final int CATEGORIES = 1;
|
||||
private static final int CATEGORIES_ID = 2;
|
||||
private static final String BASE_PATH = "categories";
|
||||
|
||||
public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH);
|
||||
|
||||
private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
|
||||
|
||||
static {
|
||||
uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES);
|
||||
uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID);
|
||||
}
|
||||
|
||||
public static Uri uriForId(int id) {
|
||||
return Uri.parse(BASE_URI.toString() + "/" + id);
|
||||
}
|
||||
|
||||
@Inject DBOpenHelper dbOpenHelper;
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder) {
|
||||
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
|
||||
queryBuilder.setTables(TABLE_NAME);
|
||||
|
||||
int uriType = uriMatcher.match(uri);
|
||||
|
||||
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
|
||||
Cursor cursor;
|
||||
|
||||
switch (uriType) {
|
||||
case CATEGORIES:
|
||||
cursor = queryBuilder.query(db, projection, selection, selectionArgs,
|
||||
null, null, sortOrder);
|
||||
break;
|
||||
case CATEGORIES_ID:
|
||||
cursor = queryBuilder.query(db,
|
||||
ALL_FIELDS,
|
||||
"_id = ?",
|
||||
new String[]{uri.getLastPathSegment()},
|
||||
null,
|
||||
null,
|
||||
sortOrder
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown URI" + uri);
|
||||
}
|
||||
|
||||
cursor.setNotificationUri(getContext().getContentResolver(), uri);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
|
||||
int uriType = uriMatcher.match(uri);
|
||||
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
|
||||
long id;
|
||||
switch (uriType) {
|
||||
case CATEGORIES:
|
||||
id = sqlDB.insert(TABLE_NAME, null, contentValues);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown URI: " + uri);
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return Uri.parse(BASE_URI + "/" + id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, String s, String[] strings) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
|
||||
Timber.d("Hello, bulk insert! (CategoryContentProvider)");
|
||||
int uriType = uriMatcher.match(uri);
|
||||
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
|
||||
sqlDB.beginTransaction();
|
||||
switch (uriType) {
|
||||
case CATEGORIES:
|
||||
for (ContentValues value : values) {
|
||||
Timber.d("Inserting! %s", value);
|
||||
sqlDB.insert(TABLE_NAME, null, value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown URI: " + uri);
|
||||
}
|
||||
sqlDB.setTransactionSuccessful();
|
||||
sqlDB.endTransaction();
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return values.length;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
|
||||
String[] selectionArgs) {
|
||||
/*
|
||||
SQL Injection warnings: First, note that we're not exposing this to the
|
||||
outside world (exported="false"). Even then, we should make sure to sanitize
|
||||
all user input appropriately. Input that passes through ContentValues
|
||||
should be fine. So only issues are those that pass in via concating.
|
||||
|
||||
In here, the only concat created argument is for id. It is cast to an int,
|
||||
and will error out otherwise.
|
||||
*/
|
||||
int uriType = uriMatcher.match(uri);
|
||||
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
|
||||
int rowsUpdated;
|
||||
switch (uriType) {
|
||||
case CATEGORIES_ID:
|
||||
if (TextUtils.isEmpty(selection)) {
|
||||
int id = Integer.valueOf(uri.getLastPathSegment());
|
||||
rowsUpdated = sqlDB.update(TABLE_NAME,
|
||||
contentValues,
|
||||
COLUMN_ID + " = ?",
|
||||
new String[]{String.valueOf(id)});
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
"Parameter `selection` should be empty when updating an ID");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
|
||||
}
|
||||
getContext().getContentResolver().notifyChange(uri, null);
|
||||
return rowsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
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 androidx.annotation.NonNull
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.data.DBOpenHelper
|
||||
import fr.free.nrw.commons.di.CommonsDaggerContentProvider
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var dbOpenHelper: DBOpenHelper
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
|
||||
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
|
||||
val queryBuilder = SQLiteQueryBuilder().apply {
|
||||
tables = TABLE_NAME
|
||||
}
|
||||
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val db = dbOpenHelper.readableDatabase
|
||||
|
||||
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(context?.contentResolver, uri)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? {
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
val id: Long
|
||||
when (uriType) {
|
||||
CATEGORIES -> {
|
||||
id = sqlDB.insert(TABLE_NAME, null, contentValues)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown URI: $uri")
|
||||
}
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
return Uri.parse("${Companion.BASE_URI}/$id")
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
||||
// Not implemented
|
||||
return 0
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun bulkInsert(uri: Uri, values: Array<ContentValues>): Int {
|
||||
Timber.d("Hello, bulk insert! (CategoryContentProvider)")
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
sqlDB.beginTransaction()
|
||||
when (uriType) {
|
||||
CATEGORIES -> {
|
||||
for (value in values) {
|
||||
Timber.d("Inserting! %s", value)
|
||||
sqlDB.insert(TABLE_NAME, null, value)
|
||||
}
|
||||
sqlDB.setTransactionSuccessful()
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown URI: $uri")
|
||||
}
|
||||
sqlDB.endTransaction()
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
return values.size
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun update(uri: Uri, contentValues: ContentValues?, selection: String?,
|
||||
selectionArgs: Array<String>?): Int {
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
val rowsUpdated: Int
|
||||
when (uriType) {
|
||||
CATEGORIES_ID -> {
|
||||
if (TextUtils.isEmpty(selection)) {
|
||||
val id = uri.lastPathSegment?.toInt()
|
||||
?: throw IllegalArgumentException("Invalid ID")
|
||||
rowsUpdated = sqlDB.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")
|
||||
}
|
||||
context?.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 {
|
||||
return 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 = Uri.parse("content://${BuildConfig.CATEGORY_AUTHORITY}/${Companion.BASE_PATH}")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Provider;
|
||||
|
||||
public class CategoryDao {
|
||||
|
||||
private final Provider<ContentProviderClient> clientProvider;
|
||||
|
||||
@Inject
|
||||
public CategoryDao(@Named("category") Provider<ContentProviderClient> clientProvider) {
|
||||
this.clientProvider = clientProvider;
|
||||
}
|
||||
|
||||
public void save(Category category) {
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
if (category.getContentUri() == null) {
|
||||
category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
|
||||
} else {
|
||||
db.update(category.getContentUri(), toContentValues(category), null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find persisted category in database, based on its name.
|
||||
*
|
||||
* @param name Category's name
|
||||
* @return category from database, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
Category find(String name) {
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return fromCursor(cursor);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
// This feels lazy, but to hell with checked exceptions. :)
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
db.release();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recently-used categories, ordered by descending date.
|
||||
*
|
||||
* @return a list containing recent categories
|
||||
*/
|
||||
@NonNull
|
||||
List<CategoryItem> recentCategories(int limit) {
|
||||
List<CategoryItem> items = new ArrayList<>();
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
Table.COLUMN_LAST_USED + " DESC");
|
||||
// fixme add a limit on the original query instead of falling out of the loop?
|
||||
while (cursor != null && cursor.moveToNext()
|
||||
&& cursor.getPosition() < limit) {
|
||||
if (fromCursor(cursor).getName() != null ) {
|
||||
items.add(new CategoryItem(fromCursor(cursor).getName(),
|
||||
fromCursor(cursor).getDescription(), fromCursor(cursor).getThumbnail(),
|
||||
false));
|
||||
}
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
db.release();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@SuppressLint("Range")
|
||||
Category fromCursor(Cursor cursor) {
|
||||
// Hardcoding column positions!
|
||||
return new Category(
|
||||
CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
|
||||
cursor.getString(cursor.getColumnIndex(Table.COLUMN_THUMBNAIL)),
|
||||
new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))),
|
||||
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED))
|
||||
);
|
||||
}
|
||||
|
||||
private ContentValues toContentValues(Category category) {
|
||||
ContentValues cv = new ContentValues();
|
||||
cv.put(CategoryDao.Table.COLUMN_NAME, category.getName());
|
||||
cv.put(Table.COLUMN_DESCRIPTION, category.getDescription());
|
||||
cv.put(Table.COLUMN_THUMBNAIL, category.getThumbnail());
|
||||
cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime());
|
||||
cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed());
|
||||
return cv;
|
||||
}
|
||||
|
||||
public static class Table {
|
||||
public static final String TABLE_NAME = "categories";
|
||||
|
||||
public static final String COLUMN_ID = "_id";
|
||||
static final String COLUMN_NAME = "name";
|
||||
static final String COLUMN_DESCRIPTION = "description";
|
||||
static final String COLUMN_THUMBNAIL = "thumbnail";
|
||||
static final String COLUMN_LAST_USED = "last_used";
|
||||
static final String COLUMN_TIMES_USED = "times_used";
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
public static final String[] ALL_FIELDS = {
|
||||
COLUMN_ID,
|
||||
COLUMN_NAME,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_THUMBNAIL,
|
||||
COLUMN_LAST_USED,
|
||||
COLUMN_TIMES_USED
|
||||
};
|
||||
|
||||
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
|
||||
|
||||
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
|
||||
+ COLUMN_ID + " INTEGER PRIMARY KEY,"
|
||||
+ COLUMN_NAME + " STRING,"
|
||||
+ COLUMN_DESCRIPTION + " STRING,"
|
||||
+ COLUMN_THUMBNAIL + " STRING,"
|
||||
+ COLUMN_LAST_USED + " INTEGER,"
|
||||
+ COLUMN_TIMES_USED + " INTEGER"
|
||||
+ ");";
|
||||
|
||||
public static void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT);
|
||||
}
|
||||
|
||||
public static void onDelete(SQLiteDatabase db) {
|
||||
db.execSQL(DROP_TABLE_STATEMENT);
|
||||
onCreate(db);
|
||||
}
|
||||
|
||||
public static void onUpdate(SQLiteDatabase db, int from, int to) {
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (from < 4) {
|
||||
// doesn't exist yet
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 4) {
|
||||
// table added in version 5
|
||||
onCreate(db);
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 5) {
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 17) {
|
||||
db.execSQL("ALTER TABLE categories ADD COLUMN description STRING;");
|
||||
db.execSQL("ALTER TABLE categories ADD COLUMN thumbnail STRING;");
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
Normal file
194
app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
Normal file
|
|
@ -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<ContentProviderClient>
|
||||
) {
|
||||
|
||||
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<CategoryItem> {
|
||||
val items = ArrayList<CategoryItem>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.ViewPagerAdapter;
|
||||
import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding;
|
||||
import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment;
|
||||
import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment;
|
||||
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment;
|
||||
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import fr.free.nrw.commons.wikidata.model.page.PageTitle;
|
||||
|
||||
/**
|
||||
* This activity displays details of a particular category
|
||||
* Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
|
||||
* a particular category on wikimedia commons.
|
||||
*/
|
||||
|
||||
public class CategoryDetailsActivity extends BaseActivity
|
||||
implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback {
|
||||
|
||||
|
||||
private FragmentManager supportFragmentManager;
|
||||
private CategoriesMediaFragment categoriesMediaFragment;
|
||||
private MediaDetailPagerFragment mediaDetails;
|
||||
private String categoryName;
|
||||
ViewPagerAdapter viewPagerAdapter;
|
||||
|
||||
private ActivityCategoryDetailsBinding binding;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
binding = ActivityCategoryDetailsBinding.inflate(getLayoutInflater());
|
||||
final View view = binding.getRoot();
|
||||
setContentView(view);
|
||||
supportFragmentManager = getSupportFragmentManager();
|
||||
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
|
||||
binding.viewPager.setAdapter(viewPagerAdapter);
|
||||
binding.viewPager.setOffscreenPageLimit(2);
|
||||
binding.tabLayout.setupWithViewPager(binding.viewPager);
|
||||
setSupportActionBar(binding.toolbarBinding.toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
setTabs();
|
||||
setPageTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
|
||||
* Set the fragments according to the tab selected in the viewPager.
|
||||
*/
|
||||
private void setTabs() {
|
||||
List<Fragment> fragmentList = new ArrayList<>();
|
||||
List<String> titleList = new ArrayList<>();
|
||||
categoriesMediaFragment = new CategoriesMediaFragment();
|
||||
SubCategoriesFragment subCategoryListFragment = new SubCategoriesFragment();
|
||||
ParentCategoriesFragment parentCategoriesFragment = new ParentCategoriesFragment();
|
||||
categoryName = getIntent().getStringExtra("categoryName");
|
||||
if (getIntent() != null && categoryName != null) {
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putString("categoryName", categoryName);
|
||||
categoriesMediaFragment.setArguments(arguments);
|
||||
subCategoryListFragment.setArguments(arguments);
|
||||
parentCategoriesFragment.setArguments(arguments);
|
||||
}
|
||||
fragmentList.add(categoriesMediaFragment);
|
||||
titleList.add("MEDIA");
|
||||
fragmentList.add(subCategoryListFragment);
|
||||
titleList.add("SUBCATEGORIES");
|
||||
fragmentList.add(parentCategoriesFragment);
|
||||
titleList.add("PARENT CATEGORIES");
|
||||
viewPagerAdapter.setTabData(fragmentList, titleList);
|
||||
viewPagerAdapter.notifyDataSetChanged();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the passed categoryName from the intents and displays it as the page title
|
||||
*/
|
||||
private void setPageTitle() {
|
||||
if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) {
|
||||
setTitle(getIntent().getStringExtra("categoryName"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called onClick of media inside category details (CategoryImageListFragment).
|
||||
*/
|
||||
@Override
|
||||
public void onMediaClicked(int position) {
|
||||
binding.tabLayout.setVisibility(View.GONE);
|
||||
binding.viewPager.setVisibility(View.GONE);
|
||||
binding.mediaContainer.setVisibility(View.VISIBLE);
|
||||
if (mediaDetails == null || !mediaDetails.isVisible()) {
|
||||
// set isFeaturedImage true for featured images, to include author field on media detail
|
||||
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
|
||||
FragmentManager supportFragmentManager = getSupportFragmentManager();
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.mediaContainer, mediaDetails)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
supportFragmentManager.executePendingTransactions();
|
||||
}
|
||||
mediaDetails.showImage(position);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Consumers should be simply using this method to use this activity.
|
||||
* @param context A Context of the application package implementing this class.
|
||||
* @param categoryName Name of the category for displaying its details
|
||||
*/
|
||||
public static void startYourself(Context context, String categoryName) {
|
||||
Intent intent = new Intent(context, CategoryDetailsActivity.class);
|
||||
intent.putExtra("categoryName", categoryName);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
|
||||
* @param i It is the index of which media object is to be returned which is same as
|
||||
* current index of viewPager.
|
||||
* @return Media Object
|
||||
*/
|
||||
@Override
|
||||
public Media getMediaAtPosition(int i) {
|
||||
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
|
||||
public int getTotalMediaCount() {
|
||||
return categoriesMediaFragment.getTotalMediaCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getContributionStateAt(int position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload media detail fragment once media is nominated
|
||||
*
|
||||
* @param index item position that has been nominated
|
||||
*/
|
||||
@Override
|
||||
public void refreshNominatedMedia(int index) {
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
|
||||
onBackPressed();
|
||||
onMediaClicked(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method inflates the menu in the toolbar
|
||||
*/
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
MenuInflater inflater = getMenuInflater();
|
||||
inflater.inflate(R.menu.fragment_category_detail, menu);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method handles the logic on ItemSelect in toolbar menu
|
||||
* Currently only 1 choice is available to open category details page in browser
|
||||
*/
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
|
||||
// Handle item selection
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_browser_current_category:
|
||||
PageTitle title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName);
|
||||
Utils.handleWebUrl(this, Uri.parse(title.getCanonicalUri()));
|
||||
return true;
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
return true;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called on backPressed of anyFragment in the activity.
|
||||
* If condition is called when mediaDetailFragment is opened.
|
||||
*/
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (supportFragmentManager.getBackStackEntryCount() == 1){
|
||||
binding.tabLayout.setVisibility(View.VISIBLE);
|
||||
binding.viewPager.setVisibility(View.VISIBLE);
|
||||
binding.mediaContainer.setVisibility(View.GONE);
|
||||
}
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called on success of API call for Images inside a category.
|
||||
* The viewpager will notified that number of items have changed.
|
||||
*/
|
||||
@Override
|
||||
public void viewPagerNotifyDataSetChanged() {
|
||||
if (mediaDetails!=null){
|
||||
mediaDetails.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
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.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import fr.free.nrw.commons.Media
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.Utils
|
||||
import fr.free.nrw.commons.ViewPagerAdapter
|
||||
import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding
|
||||
import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment
|
||||
import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment
|
||||
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment
|
||||
import fr.free.nrw.commons.media.MediaDetailPagerFragment
|
||||
import fr.free.nrw.commons.theme.BaseActivity
|
||||
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(),
|
||||
MediaDetailPagerFragment.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<CategoryDetailsViewModel> { categoryViewModelFactory }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityCategoryDetailsBinding.inflate(layoutInflater)
|
||||
val view = binding.root
|
||||
setContentView(view)
|
||||
supportFragmentManager = getSupportFragmentManager()
|
||||
viewPagerAdapter = ViewPagerAdapter(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() {
|
||||
val fragmentList = mutableListOf<Fragment>()
|
||||
val titleList = mutableListOf<String>()
|
||||
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!!)
|
||||
}
|
||||
fragmentList.add(categoriesMediaFragment)
|
||||
titleList.add("MEDIA")
|
||||
fragmentList.add(subCategoryListFragment)
|
||||
titleList.add("SUBCATEGORIES")
|
||||
fragmentList.add(parentCategoriesFragment)
|
||||
titleList.add("PARENT CATEGORIES")
|
||||
viewPagerAdapter.setTabData(fragmentList, titleList)
|
||||
viewPagerAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the passed categoryName from the intents and displays it as the page title
|
||||
*/
|
||||
private 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 = Utils.getPageTitle(CATEGORY_PREFIX + categoryName)
|
||||
Utils.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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) {
|
||||
CategoryDetailsViewModel(bookmarkCategoriesDao) as T
|
||||
} else {
|
||||
throw IllegalArgumentException("Unknown class name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_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 java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class CategoryEditHelper {
|
||||
private final NotificationHelper notificationHelper;
|
||||
public final PageEditClient pageEditClient;
|
||||
private final ViewUtilWrapper viewUtil;
|
||||
private final String username;
|
||||
|
||||
@Inject
|
||||
public CategoryEditHelper(NotificationHelper notificationHelper,
|
||||
@Named("commons-page-edit") PageEditClient pageEditClient,
|
||||
ViewUtilWrapper viewUtil,
|
||||
@Named("username") String username) {
|
||||
this.notificationHelper = notificationHelper;
|
||||
this.pageEditClient = pageEditClient;
|
||||
this.viewUtil = viewUtil;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public interface to edit categories
|
||||
* @param context
|
||||
* @param media
|
||||
* @param categories
|
||||
* @return
|
||||
*/
|
||||
public Single<Boolean> makeCategoryEdit(Context context, Media media, List<String> categories,
|
||||
final String wikiText) {
|
||||
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 categpries and post it on server
|
||||
*
|
||||
* @param media
|
||||
* @param categories to be added
|
||||
* @return
|
||||
*/
|
||||
private Observable<Boolean> addCategory(Media media, List<String> categories,
|
||||
final String wikiText) {
|
||||
Timber.d("thread is category adding %s", Thread.currentThread().getName());
|
||||
String summary = "Adding categories";
|
||||
final StringBuilder buffer = new StringBuilder();
|
||||
final String wikiTextWithoutCategory;
|
||||
//If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
|
||||
if (wikiText.contains("Uncategorized")) {
|
||||
wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("Uncategorized"));
|
||||
} else if (wikiText.contains("[[Category")) {
|
||||
wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("[[Category"));
|
||||
} else {
|
||||
wikiTextWithoutCategory = "";
|
||||
}
|
||||
if (categories != null && !categories.isEmpty()) {
|
||||
//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 selected some category,"None selected" should be removed from list
|
||||
for (int i = 0; i < categories.size(); i++) {
|
||||
if (!categories.get(i).equals("None selected")//Not to add "None selected" as category to wikiText
|
||||
|| !wikiText.contains("Uncategorized")) {
|
||||
buffer.append("[[Category:").append(categories.get(i)).append("]]\n");
|
||||
}
|
||||
}
|
||||
categories.remove("None selected");
|
||||
} else {
|
||||
buffer.append("{{subst:unc}}");
|
||||
}
|
||||
final String appendText = wikiTextWithoutCategory + buffer;
|
||||
return pageEditClient.edit(media.getFilename(), appendText + "\n", summary);
|
||||
}
|
||||
|
||||
private boolean showCategoryEditNotification(Context context, Media media, boolean result) {
|
||||
String message;
|
||||
String title = context.getString(R.string.category_edit_helper_show_edit_title);
|
||||
|
||||
if (result) {
|
||||
title += ": " + context.getString(R.string.category_edit_helper_show_edit_title_success);
|
||||
StringBuilder categoriesInMessage = new StringBuilder();
|
||||
List<String> mediaCategoryList = media.getCategories();
|
||||
for (String category : mediaCategoryList) {
|
||||
categoriesInMessage.append(category);
|
||||
if (category.equals(mediaCategoryList.get(mediaCategoryList.size()-1))) {
|
||||
continue;
|
||||
}
|
||||
categoriesInMessage.append(",");
|
||||
}
|
||||
|
||||
message = context.getResources().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);
|
||||
message = context.getString(R.string.category_edit_helper_edit_message_else) ;
|
||||
}
|
||||
|
||||
String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
|
||||
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_CATEGORY, browserIntent);
|
||||
return result;
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
boolean updateCategoryDisplay(List<String> categories);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
wikiText: String
|
||||
): Single<Boolean> {
|
||||
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<String>?,
|
||||
wikiText: String
|
||||
): Observable<Boolean> {
|
||||
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<String>?): Boolean
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_EDIT_CATEGORY = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
/**
|
||||
* Callback for notifying the viewpager that the number of items have changed
|
||||
* and for requesting more images when the viewpager has been scrolled to its end.
|
||||
*/
|
||||
|
||||
public interface CategoryImagesCallback {
|
||||
void viewPagerNotifyDataSetChanged();
|
||||
void onMediaClicked(int position);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
interface CategoryImagesCallback {
|
||||
fun viewPagerNotifyDataSetChanged()
|
||||
|
||||
fun onMediaClicked(position: Int)
|
||||
}
|
||||
|
|
@ -61,6 +61,21 @@ interface CategoryInterface {
|
|||
@Query("gacoffset") offset: Int,
|
||||
): Single<MwQueryResponse>
|
||||
|
||||
/**
|
||||
* Fetches non-hidden categories by titles.
|
||||
*
|
||||
* @param titles titles to fetch categories for (e.g. File:<P18 of a wikidata entity>)
|
||||
* @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<MwQueryResponse>
|
||||
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
|
||||
fun getSubCategoryList(
|
||||
@Query("gcmtitle") categoryName: String,
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.facebook.drawee.view.SimpleDraweeView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
public class GridViewAdapter extends ArrayAdapter {
|
||||
private List<Media> data;
|
||||
|
||||
public GridViewAdapter(Context context, int layoutResourceId, List<Media> data) {
|
||||
super(context, layoutResourceId, data);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds more item to the list
|
||||
* Its triggered on scrolling down in the list
|
||||
* @param images
|
||||
*/
|
||||
public void addItems(List<Media> images) {
|
||||
if (data == null) {
|
||||
data = new ArrayList<>();
|
||||
}
|
||||
data.addAll(images);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the first item in the new list with old list and returns true if they are same
|
||||
* Its triggered on successful response of the fetch images API.
|
||||
* @param images
|
||||
*/
|
||||
public boolean containsAll(List<Media> images){
|
||||
if (images == null || images.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (data == null) {
|
||||
data = new ArrayList<>();
|
||||
return false;
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String fileName = data.get(0).getFilename();
|
||||
String imageName = images.get(0).getFilename();
|
||||
return imageName.equals(fileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return data == null || data.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the UI for the category image item
|
||||
* @param position
|
||||
* @param convertView
|
||||
* @param parent
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent) {
|
||||
|
||||
if (convertView == null) {
|
||||
convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_category_images, null);
|
||||
}
|
||||
|
||||
Media item = data.get(position);
|
||||
SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView);
|
||||
TextView fileName = convertView.findViewById(R.id.categoryImageTitle);
|
||||
TextView uploader = convertView.findViewById(R.id.categoryImageAuthor);
|
||||
fileName.setText(item.getMostRelevantCaption());
|
||||
setUploaderView(item, uploader);
|
||||
imageView.setImageURI(item.getThumbUrl());
|
||||
return convertView;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the Media item at the given position
|
||||
*/
|
||||
@Nullable
|
||||
@Override
|
||||
public Media getItem(int position) {
|
||||
return data.get(position);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows author information if its present
|
||||
* @param item
|
||||
* @param uploader
|
||||
*/
|
||||
private void setUploaderView(Media item, TextView uploader) {
|
||||
if (!TextUtils.isEmpty(item.getAuthor())) {
|
||||
uploader.setVisibility(View.VISIBLE);
|
||||
uploader.setText(getContext().getString(R.string.image_uploaded_by, item.getUser()));
|
||||
} else {
|
||||
uploader.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Media>?
|
||||
) : ArrayAdapter<Media>(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<Media>) {
|
||||
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<Media>?): 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<SimpleDraweeView>(R.id.categoryImageView)
|
||||
val fileName = view.findViewById<TextView>(R.id.categoryImageTitle)
|
||||
val uploader = view.findViewById<TextView>(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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface OnCategoriesSaveHandler {
|
||||
void onCategoriesSave(List<String> categories);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
interface OnCategoriesSaveHandler {
|
||||
fun onCategoriesSave(categories: List<String>)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package fr.free.nrw.commons.concurrency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
|
||||
public class BackgroundPoolExceptionHandler implements 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
|
||||
public void onException(@NonNull final Throwable t) {
|
||||
//Crash for debug build
|
||||
if (BuildConfig.DEBUG) {
|
||||
Thread thread = new Thread(() -> {
|
||||
throw new RuntimeException(t);
|
||||
});
|
||||
thread.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package fr.free.nrw.commons.concurrency;
|
||||
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
class ExceptionAwareThreadPoolExecutor extends ScheduledThreadPoolExecutor {
|
||||
|
||||
private final ExceptionHandler exceptionHandler;
|
||||
|
||||
public ExceptionAwareThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory,
|
||||
ExceptionHandler exceptionHandler) {
|
||||
super(corePoolSize, threadFactory);
|
||||
this.exceptionHandler = exceptionHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterExecute(Runnable r, Throwable t) {
|
||||
super.afterExecute(r, t);
|
||||
if (t == null && r instanceof Future<?>) {
|
||||
try {
|
||||
Future<?> future = (Future<?>) r;
|
||||
if (future.isDone()) future.get();
|
||||
} catch (CancellationException | InterruptedException e) {
|
||||
//ignore
|
||||
} catch (ExecutionException e) {
|
||||
t = e.getCause() != null ? e.getCause() : e;
|
||||
} catch (Exception e) {
|
||||
t = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (t != null) {
|
||||
exceptionHandler.onException(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (e: CancellationException) {
|
||||
// ignore
|
||||
} catch (e: InterruptedException) {
|
||||
// ignore
|
||||
} catch (e: ExecutionException) {
|
||||
throwable = e.cause ?: e
|
||||
} catch (e: Exception) {
|
||||
throwable = e
|
||||
}
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
exceptionHandler?.onException(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package fr.free.nrw.commons.concurrency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface ExceptionHandler {
|
||||
void onException(@NonNull Throwable t);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package fr.free.nrw.commons.concurrency
|
||||
|
||||
interface ExceptionHandler {
|
||||
|
||||
fun onException(t: Throwable)
|
||||
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package fr.free.nrw.commons.concurrency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
public class ThreadPoolService implements Executor {
|
||||
private final ScheduledThreadPoolExecutor backgroundPool;
|
||||
|
||||
private ThreadPoolService(final Builder b) {
|
||||
backgroundPool = new ExceptionAwareThreadPoolExecutor(b.poolSize,
|
||||
new ThreadFactory() {
|
||||
int count = 0;
|
||||
@Override
|
||||
public Thread newThread(@NonNull Runnable r) {
|
||||
count++;
|
||||
Thread t = new Thread(r, String.format("%s-%s", b.name, count));
|
||||
//If the priority is specified out of range, we set the thread priority to Thread.MIN_PRIORITY
|
||||
//It's done prevent IllegalArgumentException and to prevent setting of improper high priority for a less priority task
|
||||
t.setPriority(b.priority > Thread.MAX_PRIORITY || b.priority < Thread.MIN_PRIORITY ?
|
||||
Thread.MIN_PRIORITY : b.priority);
|
||||
return t;
|
||||
}
|
||||
}, b.exceptionHandler);
|
||||
}
|
||||
|
||||
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long time, TimeUnit timeUnit) {
|
||||
return backgroundPool.schedule(callable, time, timeUnit);
|
||||
}
|
||||
|
||||
public ScheduledFuture<?> schedule(Runnable runnable) {
|
||||
return schedule(runnable, 0, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public ScheduledFuture<?> schedule(Runnable runnable, long time, TimeUnit timeUnit) {
|
||||
return backgroundPool.schedule(runnable, time, timeUnit);
|
||||
}
|
||||
|
||||
public ScheduledFuture<?> scheduleAtFixedRate(final Runnable task, long initialDelay,
|
||||
long period, final TimeUnit timeUnit) {
|
||||
return backgroundPool.scheduleAtFixedRate(task, initialDelay, period, timeUnit);
|
||||
}
|
||||
|
||||
public ScheduledThreadPoolExecutor executor() {
|
||||
return backgroundPool;
|
||||
}
|
||||
|
||||
public void shutdown(){
|
||||
backgroundPool.shutdown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
backgroundPool.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for {@link ThreadPoolService}
|
||||
*/
|
||||
public static class Builder {
|
||||
//Required
|
||||
private final String name;
|
||||
|
||||
//Optional
|
||||
private int poolSize = 1;
|
||||
private int priority = Thread.MIN_PRIORITY;
|
||||
private ExceptionHandler exceptionHandler = null;
|
||||
|
||||
/**
|
||||
* @param name the name of the threads in the service. if there are N threads,
|
||||
* the thread names will be like name-1, name-2, name-3,...,name-N
|
||||
*/
|
||||
public Builder(@NonNull String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param poolSize the number of threads to keep in the pool
|
||||
* @throws IllegalArgumentException if size of pool <=0
|
||||
*/
|
||||
public Builder setPoolSize(int poolSize) throws IllegalArgumentException {
|
||||
if (poolSize <= 0) {
|
||||
throw new IllegalArgumentException("Pool size must be grater than 0");
|
||||
}
|
||||
this.poolSize = poolSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param priority Priority of the threads in the service. You can supply a constant from
|
||||
* {@link 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 {@link java.lang.Thread#MIN_PRIORITY}
|
||||
*/
|
||||
public Builder setPriority(int priority) {
|
||||
this.priority = priority;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param handler The handler to use to handle exceptions in the service
|
||||
*/
|
||||
public Builder setExceptionHandler(ExceptionHandler handler) {
|
||||
this.exceptionHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ThreadPoolService build() {
|
||||
return new ThreadPoolService(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <V> schedule(callable: Callable<V>, time: Long, timeUnit: TimeUnit): ScheduledFuture<V> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,13 +63,13 @@ data class Contribution constructor(
|
|||
Media(
|
||||
formatCaptions(item.uploadMediaDetails),
|
||||
categories,
|
||||
item.fileName,
|
||||
item.filename,
|
||||
formatDescriptions(item.uploadMediaDetails),
|
||||
sessionManager.userName,
|
||||
sessionManager.userName,
|
||||
),
|
||||
localUri = item.mediaUri,
|
||||
decimalCoords = item.gpsCoords.decimalCoords,
|
||||
decimalCoords = item.gpsCoords?.decimalCoords,
|
||||
dateCreatedSource = "",
|
||||
depictedItems = depictedItems,
|
||||
wikidataPlace = from(item.place),
|
||||
|
|
@ -112,7 +112,7 @@ data class Contribution constructor(
|
|||
*/
|
||||
fun formatDescriptions(descriptions: List<UploadMediaDetail>) =
|
||||
descriptions
|
||||
.filter { it.descriptionText.isNotEmpty() }
|
||||
.filter { !it.descriptionText.isNullOrEmpty() }
|
||||
.joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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
|
||||
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
|
||||
|
|
@ -20,7 +20,7 @@ class ContributionBoundaryCallback
|
|||
private val repository: ContributionsRepository,
|
||||
private val sessionManager: SessionManager,
|
||||
private val mediaClient: MediaClient,
|
||||
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler,
|
||||
@param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler,
|
||||
) : BoundaryCallback<Contribution>() {
|
||||
private val compositeDisposable: CompositeDisposable = CompositeDisposable()
|
||||
var userName: String? = null
|
||||
|
|
@ -30,10 +30,7 @@ class ContributionBoundaryCallback
|
|||
* network
|
||||
*/
|
||||
override fun onZeroItemsLoaded() {
|
||||
if (sessionManager.userName != null) {
|
||||
mediaClient.resetUserNameContinuation(sessionManager.userName!!)
|
||||
}
|
||||
fetchContributions()
|
||||
refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,9 +48,25 @@ class ContributionBoundaryCallback
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetches contributions using the MediaWiki API
|
||||
* Fetch list from network and save it to local DB.
|
||||
*
|
||||
* @param onRefreshFinish callback to invoke when operations finishes
|
||||
* with either error or success.
|
||||
*/
|
||||
private fun fetchContributions() {
|
||||
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 ->
|
||||
|
|
@ -64,12 +77,15 @@ class ContributionBoundaryCallback
|
|||
Contribution(media = media, state = Contribution.STATE_COMPLETED)
|
||||
}
|
||||
}.subscribeOn(ioThreadScheduler)
|
||||
.subscribe(::saveContributionsToDB) { error: Throwable ->
|
||||
.subscribe({ list ->
|
||||
saveContributionsToDB(list, onRefreshFinish)
|
||||
},{ error ->
|
||||
onRefreshFinish()
|
||||
Timber.e(
|
||||
"Failed to fetch contributions: %s",
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
})
|
||||
}?.let {
|
||||
compositeDisposable.add(
|
||||
it,
|
||||
|
|
@ -82,13 +98,16 @@ class ContributionBoundaryCallback
|
|||
|
||||
/**
|
||||
* Saves the contributions the the local DB
|
||||
*
|
||||
* @param onRefreshFinish callback to invoke when successfully saved to DB.
|
||||
*/
|
||||
private fun saveContributionsToDB(contributions: List<Contribution>) {
|
||||
private fun saveContributionsToDB(contributions: List<Contribution>, onRefreshFinish: () -> Unit) {
|
||||
compositeDisposable.add(
|
||||
repository
|
||||
.save(contributions)
|
||||
.subscribeOn(ioThreadScheduler)
|
||||
.subscribe { longs: List<Long?>? ->
|
||||
onRefreshFinish()
|
||||
repository["last_fetch_timestamp"] = System.currentTimeMillis()
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,403 +0,0 @@
|
|||
package fr.free.nrw.commons.contributions;
|
||||
|
||||
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
|
||||
|
||||
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.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.paging.DataSource.Factory;
|
||||
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.ImageSource;
|
||||
import fr.free.nrw.commons.filepicker.UploadableFile;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.location.LatLng;
|
||||
import fr.free.nrw.commons.location.LocationPermissionsHelper;
|
||||
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
|
||||
import fr.free.nrw.commons.location.LocationServiceManager;
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
import fr.free.nrw.commons.upload.UploadActivity;
|
||||
import fr.free.nrw.commons.utils.DialogUtil;
|
||||
import fr.free.nrw.commons.utils.PermissionUtils;
|
||||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class ContributionController {
|
||||
|
||||
public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads";
|
||||
private final JsonKvStore defaultKvStore;
|
||||
private LatLng locationBeforeImageCapture;
|
||||
private boolean isInAppCameraUpload;
|
||||
public LocationPermissionCallback locationPermissionCallback;
|
||||
private LocationPermissionsHelper locationPermissionsHelper;
|
||||
LiveData<PagedList<Contribution>> failedAndPendingContributionList;
|
||||
LiveData<PagedList<Contribution>> pendingContributionList;
|
||||
LiveData<PagedList<Contribution>> failedContributionList;
|
||||
|
||||
@Inject
|
||||
LocationServiceManager locationManager;
|
||||
|
||||
@Inject
|
||||
ContributionsRepository repository;
|
||||
|
||||
@Inject
|
||||
public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) {
|
||||
this.defaultKvStore = defaultKvStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for permissions and initiate camera click
|
||||
*/
|
||||
public void initiateCameraPick(Activity activity,
|
||||
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
|
||||
ActivityResultLauncher<Intent> resultLauncher) {
|
||||
boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true);
|
||||
if (!useExtStorage) {
|
||||
initiateCameraUpload(activity, resultLauncher);
|
||||
return;
|
||||
}
|
||||
|
||||
PermissionUtils.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,
|
||||
PermissionUtils.getPERMISSIONS_STORAGE());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users to provide location access
|
||||
*
|
||||
* @param activity
|
||||
*/
|
||||
private void createDialogsAndHandleLocationPermissions(Activity activity,
|
||||
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
|
||||
ActivityResultLauncher<Intent> resultLauncher) {
|
||||
locationPermissionCallback = new LocationPermissionCallback() {
|
||||
@Override
|
||||
public void onLocationPermissionDenied(String toastMessage) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
toastMessage,
|
||||
Toast.LENGTH_LONG
|
||||
).show();
|
||||
initiateCameraUpload(activity, resultLauncher);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void 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 = new LocationPermissionsHelper(
|
||||
activity, locationManager, locationPermissionCallback);
|
||||
if (inAppCameraLocationPermissionLauncher != null) {
|
||||
inAppCameraLocationPermissionLauncher.launch(
|
||||
new String[]{permission.ACCESS_FINE_LOCATION});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog alerting the user about location services being off and asking them to turn it
|
||||
* on
|
||||
* TODO: Add a seperate callback in LocationPermissionsHelper for this.
|
||||
* Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
|
||||
*
|
||||
* @param activity Activity reference
|
||||
* @param dialogTextResource Resource id of text to be shown in dialog
|
||||
* @param toastTextResource Resource id of text to be shown in toast
|
||||
* @param resultLauncher
|
||||
*/
|
||||
private void showLocationOffDialog(Activity activity, int dialogTextResource,
|
||||
int toastTextResource, ActivityResultLauncher<Intent> resultLauncher) {
|
||||
DialogUtil
|
||||
.showAlertDialog(activity,
|
||||
activity.getString(R.string.ask_to_turn_location_on),
|
||||
activity.getString(dialogTextResource),
|
||||
activity.getString(R.string.title_app_shortcut_setting),
|
||||
activity.getString(R.string.cancel),
|
||||
() -> locationPermissionsHelper.openLocationSettings(activity),
|
||||
() -> {
|
||||
Toast.makeText(activity, activity.getString(toastTextResource),
|
||||
Toast.LENGTH_LONG).show();
|
||||
initiateCameraUpload(activity, resultLauncher);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public void handleShowRationaleFlowCameraLocation(Activity activity,
|
||||
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
|
||||
ActivityResultLauncher<Intent> resultLauncher) {
|
||||
DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title),
|
||||
activity.getString(R.string.in_app_camera_location_permission_rationale),
|
||||
activity.getString(android.R.string.ok),
|
||||
activity.getString(android.R.string.cancel),
|
||||
() -> {
|
||||
createDialogsAndHandleLocationPermissions(activity,
|
||||
inAppCameraLocationPermissionLauncher, resultLauncher);
|
||||
},
|
||||
() -> locationPermissionCallback.onLocationPermissionDenied(
|
||||
activity.getString(R.string.in_app_camera_location_permission_denied)),
|
||||
null,
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest user to attach location information with pictures. If the user selects "Yes", then:
|
||||
* <p>
|
||||
* Location is taken from the EXIF if the default camera application does not redact location
|
||||
* tags.
|
||||
* <p>
|
||||
* Otherwise, if the EXIF metadata does not have location information, then location captured by
|
||||
* the app is used
|
||||
*
|
||||
* @param activity
|
||||
*/
|
||||
private void askUserToAllowLocationAccess(Activity activity,
|
||||
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
|
||||
ActivityResultLauncher<Intent> resultLauncher) {
|
||||
DialogUtil.showAlertDialog(activity,
|
||||
activity.getString(R.string.in_app_camera_location_permission_title),
|
||||
activity.getString(R.string.in_app_camera_location_access_explanation),
|
||||
activity.getString(R.string.option_allow),
|
||||
activity.getString(R.string.option_dismiss),
|
||||
() -> {
|
||||
defaultKvStore.putBoolean("inAppCameraLocationPref", true);
|
||||
createDialogsAndHandleLocationPermissions(activity,
|
||||
inAppCameraLocationPermissionLauncher, resultLauncher);
|
||||
},
|
||||
() -> {
|
||||
ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied);
|
||||
defaultKvStore.putBoolean("inAppCameraLocationPref", false);
|
||||
initiateCameraUpload(activity, resultLauncher);
|
||||
},
|
||||
null,
|
||||
true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate gallery picker
|
||||
*/
|
||||
public void initiateGalleryPick(final Activity activity, ActivityResultLauncher<Intent> resultLauncher, final boolean allowMultipleUploads) {
|
||||
initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate gallery picker with permission
|
||||
*/
|
||||
public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
|
||||
setPickerConfiguration(activity, true);
|
||||
|
||||
PermissionUtils.checkPermissionsAndPerformAction(activity,
|
||||
() -> FilePicker.openCustomSelector(activity, resultLauncher, 0),
|
||||
R.string.storage_permission_title,
|
||||
R.string.write_storage_permission_rationale,
|
||||
PermissionUtils.getPERMISSIONS_STORAGE());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Open chooser for gallery uploads
|
||||
*/
|
||||
private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher<Intent> resultLauncher,
|
||||
final boolean allowMultipleUploads) {
|
||||
setPickerConfiguration(activity, allowMultipleUploads);
|
||||
FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets configuration for file picker
|
||||
*/
|
||||
private void setPickerConfiguration(Activity activity,
|
||||
boolean allowMultipleUploads) {
|
||||
boolean copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true);
|
||||
FilePicker.configuration(activity)
|
||||
.setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage)
|
||||
.setAllowMultiplePickInGallery(allowMultipleUploads);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate camera upload by opening camera
|
||||
*/
|
||||
private void initiateCameraUpload(Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
|
||||
setPickerConfiguration(activity, false);
|
||||
if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) {
|
||||
locationBeforeImageCapture = locationManager.getLastLocation();
|
||||
}
|
||||
isInAppCameraUpload = true;
|
||||
FilePicker.openCameraForImage(activity, resultLauncher, 0);
|
||||
}
|
||||
|
||||
private boolean isDocumentPhotoPickerPreferred(){
|
||||
return defaultKvStore.getBoolean(
|
||||
"openDocumentPhotoPickerPref", true);
|
||||
}
|
||||
|
||||
public void onPictureReturnedFromGallery(ActivityResult result, Activity activity, FilePicker.Callbacks callbacks){
|
||||
|
||||
if(isDocumentPhotoPickerPreferred()){
|
||||
FilePicker.onPictureReturnedFromDocuments(result, activity, callbacks);
|
||||
} else {
|
||||
FilePicker.onPictureReturnedFromGallery(result, activity, callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
public void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks);
|
||||
}
|
||||
|
||||
public void onPictureReturnedFromCamera(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
FilePicker.onPictureReturnedFromCamera(result, activity, callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches callback for file picker.
|
||||
*/
|
||||
public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) {
|
||||
|
||||
handleActivityResult.onHandleActivityResult(new DefaultCallback() {
|
||||
|
||||
@Override
|
||||
public void onCanceled(final ImageSource source, final int type) {
|
||||
super.onCanceled(source, type);
|
||||
defaultKvStore.remove(PLACE_OBJECT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onImagePickerError(Exception e, FilePicker.ImageSource source,
|
||||
int type) {
|
||||
ViewUtil.showShortToast(activity, R.string.error_occurred_in_picking_images);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onImagesPicked(@NonNull List<UploadableFile> imagesFiles,
|
||||
FilePicker.ImageSource source, int type) {
|
||||
Intent intent = handleImagesPicked(activity, imagesFiles);
|
||||
activity.startActivity(intent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<UploadableFile> handleExternalImagesPicked(Activity activity,
|
||||
Intent data) {
|
||||
return FilePicker.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 Intent handleImagesPicked(Context context,
|
||||
List<UploadableFile> imagesFiles) {
|
||||
Intent shareIntent = new Intent(context, UploadActivity.class);
|
||||
shareIntent.setAction(ACTION_INTERNAL_UPLOADS);
|
||||
shareIntent
|
||||
.putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, new ArrayList<>(imagesFiles));
|
||||
Place place = defaultKvStore.getJson(PLACE_OBJECT, Place.class);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it
|
||||
* populates the `pendingContributionList`.
|
||||
**/
|
||||
void getPendingContributions() {
|
||||
final PagedList.Config pagedListConfig =
|
||||
(new PagedList.Config.Builder())
|
||||
.setPrefetchDistance(50)
|
||||
.setPageSize(10).build();
|
||||
Factory<Integer, Contribution> factory;
|
||||
factory = repository.fetchContributionsWithStates(
|
||||
Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
|
||||
Contribution.STATE_PAUSED));
|
||||
|
||||
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
|
||||
pagedListConfig);
|
||||
pendingContributionList = livePagedListBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the contributions with the state "FAILED" and populates the
|
||||
* `failedContributionList`.
|
||||
**/
|
||||
void getFailedContributions() {
|
||||
final PagedList.Config pagedListConfig =
|
||||
(new PagedList.Config.Builder())
|
||||
.setPrefetchDistance(50)
|
||||
.setPageSize(10).build();
|
||||
Factory<Integer, Contribution> factory;
|
||||
factory = repository.fetchContributionsWithStates(
|
||||
Collections.singletonList(Contribution.STATE_FAILED));
|
||||
|
||||
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
|
||||
pagedListConfig);
|
||||
failedContributionList = livePagedListBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Integer, Contribution> 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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue