Merge remote-tracking branch 'origin/main' into jetpack-custom-selector

This commit is contained in:
Rohit Verma 2025-05-27 09:31:59 +05:30
commit 3a3bb4e7c2
712 changed files with 38946 additions and 35850 deletions

View file

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

View 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
});

3
.gitignore vendored
View file

@ -46,4 +46,5 @@ captures/*
# Test and other output
app/jacoco.exec
app/CommonsContributions
app/CommonsContributions
app/.*

View file

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

View file

@ -1,6 +1,6 @@
# Wikimedia Commons Android app
![GitHub issue custom search](https://img.shields.io/github/issues-search?label=%22good%20first%20issue%22%20issues&query=repo%3Acommons-app%2Fapps-android-commons%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22)
[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=master)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amaster)
[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain)
[![Preview the app](https://img.shields.io/badge/Preview-Appetize.io-orange.svg)](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw)
[![codecov](https://codecov.io/gh/commons-app/apps-android-commons/branch/master/graph/badge.svg)](https://codecov.io/gh/commons-app/apps-android-commons)
@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
@ -259,4 +257,4 @@
android:required="false" />
</application>
</manifest>
</manifest>

View file

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

View 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
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -23,13 +23,23 @@ class MediaDataExtractor
private val mediaClient: MediaClient,
) {
fun fetchDepictionIdsAndLabels(media: Media) =
mediaClient
mediaClient
.getEntities(media.depictionIds)
.map {
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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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"
}
}

View file

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

View 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)
}
}
}
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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?,
)

View file

@ -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. ")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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,30 +125,64 @@ 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>>? =
Observable
.fromIterable(
selectedDepictions.map { it.commonsCategories }.flatten(),
).map { categoryItem ->
categoryClient
.getCategoriesByName(
categoryItem.name,
categoryItem.name,
SEARCH_CATS_LIMIT,
).map {
CategoryItem(
it[0].name,
it[0].description,
it[0].thumbnail,
it[0].isSelected,
)
}.blockingGet()
}.toList()
.toObservable()
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(
depictedItem.commonsCategories,
).map { categoryItem ->
categoryClient
.getCategoriesByName(
categoryItem.name,
categoryItem.name,
SEARCH_CATS_LIMIT,
).map {
CategoryItem(
it[0].name,
it[0].description,
it[0].thumbnail,
it[0].isSelected,
)
}.blockingGet()
}.toList()
.toObservable()
}
}
return Observable.concat(observables)
.scan(mutableListOf<CategoryItem>()) { accumulator, currentList ->
accumulator.apply { addAll(currentList) }
}
}
/**
* Fetches details of every category by their name, converts them into

View file

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

View 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++
}
}

View file

@ -1,5 +0,0 @@
package fr.free.nrw.commons.category;
public interface CategoryClickedListener {
void categoryClicked(CategoryItem item);
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.category
interface CategoryClickedListener {
fun categoryClicked(item: CategoryItem)
}

View file

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

View file

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

View file

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

View file

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

View 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)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.category
interface CategoryImagesCallback {
fun viewPagerNotifyDataSetChanged()
fun onMediaClicked(position: Int)
}

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.category;
import java.util.List;
public interface OnCategoriesSaveHandler {
void onCategoriesSave(List<String> categories);
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.category
interface OnCategoriesSaveHandler {
fun onCategoriesSave(categories: List<String>)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.concurrency;
import androidx.annotation.NonNull;
public interface ExceptionHandler {
void onException(@NonNull Throwable t);
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.concurrency
interface ExceptionHandler {
fun onException(t: Throwable)
}

View file

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

View file

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

View file

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

View file

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

View file

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