Compare commits

..

10 commits
main ... v2.13

Author SHA1 Message Date
Josephine Lim
d950f72193 Merge branch '2.13-release-2' of https://github.com/misaochan/apps-android-commons into 2.13-release-2 2020-05-20 02:57:41 +10:00
Josephine Lim
d97d40fbb9 Versioning for v2.13 2020-05-20 02:56:59 +10:00
Josephine Lim
741746892a
Update changelog.md 2020-05-20 02:55:46 +10:00
Ashish Kumar
bff923135e
Fix NullPointer when clicking on image in MediaDetailFragment (#3730)… (#3739) 2020-05-12 13:37:17 +03:00
Vitaly V. Pinchuk
63018fcbd5 Add #3723 and #3721 to 2.13 release, fix conflicts
Conflicts were caused by merging #3723 before #3721 , so I just rolled both into one commit.
2020-05-06 01:04:09 +10:00
Ashish Kumar
461249fc30
Fixes #3639 (Fix Save State implementation of CheckBoxTriState ) (#3686) 2020-04-26 12:05:08 +03:00
Kshitij Bhardwaj
d7c2480174 Fixes #3436 and #2881: Media Detail design Overhaul (#3505)
* ic_map_dark_24dp: map icon for white background

* ic_info_outline_dark_24dp: info icon for dark background

* MediaDetailFragment: update the spacer as per image aspect ratio

* fragment_media_detail: design overhaul

* fragment_media_detail: remove redundant background color statements

* make requested changes

* add dark mode support

* minor ui tweak

* white map icon in dark mode

* make rquested changes

* make requested changes to layout

* fix misalignment of category list

* subtle amendments

* convert comments to javadocs

* minor amendments

* minor changes

* add styles for media detail

* Media detail fragment refactored

* make suggested changes

* minor name fix

* fix the delete button border
2020-04-22 01:23:14 +10:00
Ashish Kumar
05a9aa8575
Bugfix/security exception (#3627)
* Fixes #3626
* Check is file is actually created before writing to file, file picker android

* Handle Security exception
2020-04-16 23:59:30 -07:00
Kaartic Sivaraam
c961099013
Revert "Fixes: #3179 Make category search non case-sensitive (#3326)" (#3636)
Simply lower casing the name of the category sent to the server
doesn't result in the server doing a case insensitive category
search. In fact, it reduces the category search space as only
categories that has a lower case character is searched even
if the search text contains upper case characters.

The test case did not catch this issue as the first character
of the title is case insensitive[1].

So, revert the changes done in commit afdeaae075.

See further disucssion in the issue thread of #3179 starting
from [2].

[1]: https://www.mediawiki.org/wiki/Manual:Page_title
[2]: https://github.com/commons-app/apps-android-commons/issues/3179#issuecomment-605462140
2020-04-10 10:38:51 +01:00
Seán Mac Gillicuddy
cf73e28623 #3624 DateTimeFormat wrong - match pattern returned from servers (#3625) 2020-04-09 02:52:09 +10:00
1541 changed files with 51932 additions and 137511 deletions

View file

@ -1,85 +0,0 @@
name: "\U0001F41E Bug report"
description: Create a report to help us improve.
title: "[Bug]: "
type: Bug # Retained to categorize the issue as per organization-level type
body:
- type: markdown
attributes:
value: |
- Before creating an issue, please search the existing issues to see if a similar one has already been created.
- You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter.
- type: textarea
attributes:
label: Summary
description: Summarize your issue (what goes wrong, what did you expect to happen)
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: How can we reproduce the issue?
placeholder: |
1. Have the app open..
2. Go to..
3. Click on..
4. Observe..
validations:
required: true
- type: textarea
attributes:
label: Expected behaviour
placeholder: A menu should open..
validations:
required: true
- type: textarea
attributes:
label: Actual behaviour
placeholder: The app closes unexpectedly..
validations:
required: true
- type: markdown
attributes:
value: |
# Device information
- type: input
attributes:
label: Device name
description: What make and model device did you encounter this on?
placeholder: Samsung J7
validations:
required: false
- type: input
attributes:
label: Android version
description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ?
placeholder: Android 10
validations:
required: true
- type: input
attributes:
label: Commons app version
description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`).
placeholder: 3.1.1
validations:
required: true
- type: textarea
attributes:
label: Device logs
description: Add logcat files here (if possible). Need help? See "[Getting app logs from Android Studio](https://commons-app.github.io/docs.html#getting-app-logs-from-android-studio)".
validations:
required: false
- type: textarea
attributes:
label: Screenshots
description: Add screenshots related to the issue (if available). Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher.
validations:
required: false
- type: dropdown
attributes:
label: Would you like to work on the issue?
description: Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them.
options:
- "Yes"
- Prefer not
validations:
required: false

View file

@ -1,30 +0,0 @@
name: "⭐️ Feature request"
description: Suggest an idea for this project
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
- Please do your best to search for duplicate issues before filing a new issue so we can keep our issue board clean.
- Every issue should have exactly one feature request described in it. Please do not file feedback list tickets as it is difficult to parse them and address their individual points.
- Feature Requests are better when theyre open-ended instead of demanding a specific solution e.g: “I want an easier way to do X” instead of “add Y”.
- type: textarea
attributes:
label: What is the user problem or growth opportunity you want to see solved?
validations:
required: false
- type: textarea
attributes:
label: How do you know that this problem exists today? Why is this important?
validations:
required: false
- type: textarea
attributes:
label: Who will benefit from it?
validations:
required: false
- type: textarea
attributes:
label: Anything else you would like to add?
validations:
required: false

View file

@ -1,46 +0,0 @@
name: "\U0001F4AC Feedback"
description: Share your feedback about the app
labels: ["feedback"]
body:
- type: markdown
attributes:
value: |
- Before creating an issue, please search the existing issues to see if a similar one has already been created.
- You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter.
- type: textarea
attributes:
label: Feedback
description: Share your feedback about the app.
validations:
required: true
- type: input
attributes:
label: Wiki username
placeholder: Jimbo Wales
validations:
required: false
- type: markdown
attributes:
value: |
# Device information
- type: input
attributes:
label: Device name
description: What make and model device did you encounter this on?
placeholder: Samsung J7
validations:
required: false
- type: input
attributes:
label: Android version
description: What Android version (e.g., Android 6.0 Marshmallow or Android 11) are you running? Is it the stock version from the manufacturer or a custom ROM ?
placeholder: Android 10
validations:
required: false
- type: input
attributes:
label: Commons app version
description: You can find this information by clicking the right-most menu in the bottom navigation bar in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. `master` and `prodDebug`).
placeholder: 3.1.1
validations:
required: true

View file

@ -1,13 +0,0 @@
name: "✋🏻 Need help"
description: Describe the situation which you need help with.
labels: ["help needed"]
body:
- type: markdown
attributes:
value: |
- Describe the situation which you need help with with as much information as possible.
- type: textarea
attributes:
label: Description
validations:
required: true

View file

@ -1,114 +0,0 @@
name: Android CI
on: [push, pull_request, workflow_dispatch]
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Run tests and generate APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache packages
id: cache-packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-packages-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }}
restore-keys: gradle-packages-${{ runner.os }}
- name: Access test login credentials
run: |
echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties
echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties
- name: AVD cache
if: github.event_name != 'pull_request'
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-tablet-api-24
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 24
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: echo "Generated AVD snapshot for caching."
- name: Run Instrumentation tests
if: github.event_name != 'pull_request'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 24
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
profile: Nexus 10
script: |
adb shell content insert --uri content://settings/system --bind name:s:accelerometer_rotation --bind value:i:0
adb shell content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:0
adb emu geo fix 37.422131 -122.084801
./gradlew connectedBetaDebugAndroidTest --stacktrace
- name: Run Unit tests with unified coverage
if: github.event_name != 'pull_request'
run: ./gradlew -Pcoverage testBetaDebugUnitTestUnifiedCoverage --stacktrace
- name: Run Unit tests without unified coverage
if: github.event_name == 'pull_request'
run: ./gradlew -Pcoverage testBetaDebugUnitTestCoverage --stacktrace
- name: Upload Test Report to Codecov
if: github.event_name != 'pull_request'
run: |
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -f "app/build/reports/jacoco/testBetaDebugUnitTestUnifiedCoverage/testBetaDebugUnitTestUnifiedCoverage.xml" -Z
- name: Generate betaDebug APK
run: bash ./gradlew assembleBetaDebug --stacktrace
- name: Upload betaDebug APK
uses: actions/upload-artifact@v4
with:
name: betaDebugAPK
path: app/build/outputs/apk/beta/debug/app-*.apk
- name: Generate prodDebug APK
run: bash ./gradlew assembleProdDebug --stacktrace
- name: Upload prodDebug APK
uses: actions/upload-artifact@v4
with:
name: prodDebugAPK
path: app/build/outputs/apk/prod/debug/app-*.apk
- name: Create and PR number artifact
run: |
echo "{\"pr_number\": ${{ github.event.pull_request.number || 'null' }}}" > pr_number.json
- name: Upload PR number artifact
uses: actions/upload-artifact@v4
with:
name: pr_number
path: ./pr_number.json

View file

@ -1,41 +0,0 @@
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

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

6
.gitignore vendored
View file

@ -7,7 +7,6 @@ app/src/main/gen/*
# IDEA/Android Studio Ignore exceptions
!/.idea/codeStyles/
!/.idea/inspectionProfiles/
# Gradle
.gradle
@ -43,8 +42,3 @@ app/src/main/jniLibs
#https://docs.opencv.org/3.3.0/
/libraries/opencv/javadoc/
captures/*
# Test and other output
app/jacoco.exec
app/CommonsContributions
app/.*

View file

@ -7,6 +7,13 @@
<option name="TAB_SIZE" value="2" />
</value>
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JSCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</JSCodeStyleSettings>
@ -16,7 +23,6 @@
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="999" />
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
@ -40,18 +46,21 @@
<option name="ALIGN_INIT_LIST_IN_COLUMNS" value="false" />
<option name="SPACE_BEFORE_SUPERCLASS_COLON" value="false" />
</Objective-C>
<Objective-C-extensions>
<extensions>
<pair source="cc" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</Objective-C-extensions>
<Python>
<option name="USE_CONTINUATION_INDENT_FOR_ARGUMENTS" value="true" />
</Python>
<TypeScriptCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</TypeScriptCodeStyleSettings>
<files>
<extensions>
<pair source="cc" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</files>
<XML>
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<codeStyleSettings language="CSS">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
@ -109,6 +118,7 @@
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
@ -238,28 +248,6 @@
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
@ -287,12 +275,211 @@
<rule>
<match>
<AND>
<NAME>.*</NAME>
<NAME>.*:.*Style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_width</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_height</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_weight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_margin</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginRight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:padding</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingRight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
@ -300,7 +487,39 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/tools</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
@ -316,7 +535,9 @@
<codeStyleSettings language="protobuf">
<option name="RIGHT_MARGIN" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>

View file

@ -1,90 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ClassWithOnlyPrivateConstructors" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ConfusingElse" enabled="true" level="WARNING" enabled_by_default="true">
<option name="reportWhenNoStatementFollow" value="true" />
</inspection_tool>
<inspection_tool class="ControlFlowStatementWithoutBraces" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="ExplicitThis" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="LocalCanBeFinal" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_VARIABLES" value="true" />
<option name="REPORT_PARAMETERS" value="true" />
</inspection_tool>
<inspection_tool class="LongLine" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="MissingOverrideAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="ignoreObjectMethods" value="true" />
<option name="ignoreAnonymousClassMethods" value="false" />
</inspection_tool>
<inspection_tool class="NonFinalUtilityClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="OverlyStrongTypeCast" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreInMatchingInstanceof" value="false" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ProblematicWhitespace" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantImplements" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreSerializable" value="false" />
<option name="ignoreCloneable" value="false" />
</inspection_tool>
<inspection_tool class="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TypeParameterExtendsFinalClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarilyQualifiedStaticUsage" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreStaticFieldAccesses" value="false" />
<option name="m_ignoreStaticMethodCalls" value="false" />
<option name="m_ignoreStaticAccessFromStaticContext" value="false" />
</inspection_tool>
<inspection_tool class="UnnecessarilyQualifiedStaticallyImportedElement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryConstantArrayCreationExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryConstructor" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreAnnotations" value="true" />
</inspection_tool>
<inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryThis" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View file

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

59
.travis.yml Normal file
View file

@ -0,0 +1,59 @@
language: android
addons:
apt:
packages:
- w3m
env:
global:
- ANDROID_TARGET=android-22
- ANDROID_ABI=armeabi-v7a
- ADB_INSTALL_TIMEOUT=12
- secure: okdkna5DaH/2Fay9vI6Enrx7u9UwRm4/IJXvcaWJcvjF3JTsLQr0r+dlMT2X5E1GsNk4WcoGcfZJcVonULkaW4S96B43g3EyevWbLFWjii0cMUO00OshToKyboSvNUf+d5B6rghrbnxTIBNel2ZBFj8MXHdtz6Az20q8VywqPeBZupo7olyKKS1nYdvoo7ypNScVjDGEjEPonWplztYlSDT1w81Vww4kF9oiOPEzDOPw1lOiD8FTyKLXhK0WYlnc3cnyFjZwVMlKcomnFYPfe/J2zO6OP/XInxYSXRkZ6wiOC5gMPYAYanUAuzm91vsTBQMk6jMCglSM9Nl6dPusGgEqOyTwLVALlgvS3km9HNVsHuVJhU+bmJ6scFBWrAOhbsV2ioSEsQ8NgU0Zv1SC0wN9ZruF4ae03Re+k+eHgwA3taZXrT2pvkkSmfRex6oFZReypcPGFQYiHo31NsO39WPRYYxr4edYisVXw75x/BJyOcUULhG1YmwHYYeXOzbNp0Sf9ADtUDi0oip/BO2tkLxbE+z1GJSmC83fX2YpoK+IwDHNm+4w8OJAJBvdxA3Q4HrJBAbd8jnQYP+sBBaki8t5WuwJmfOucx0vgKJ7pzqRY/MOUVe/dACnjLgFDLuS7MMqr6xU/oMM6/rrt4209tL+GQbn/R98UKtmMRRq1hY=
jdk:
- oraclejdk8
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- extra-google-m2repository
- extra-android-m2repository
- android-22
- android-28
licenses:
- android-sdk-license-.+
script:
- "./gradlew -Pcoverage lintBetaDebug pmd checkstyle jacocoTestBetaDebugUnitTestReport"
- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
mkdir -p app/src/prodRelease/play/release-notes/en-US;
fi
- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
git log --pretty=format:"%s" HEAD^..HEAD > app/src/prodRelease/play/release-notes/en-US/default.txt;
fi
- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
./gradlew publishProdReleaseApk;
fi
after_success:
- bash <(curl -s https://codecov.io/bash)
after_failure:
- echo '*** Debug Unit Test Results ***'
- w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/tests/*/classes/*Test.html
- echo '*** Connected Test Results ***'
- w3m -dump ${TRAVIS_BUILD_DIR}/app/build/reports/androidTests/connected/flavors/*/*Test.html
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
cache:
directories:
- "$HOME/.gradle/caches/"
- "$HOME/.gradle/wrapper/"
before_install:
- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
openssl aes-256-cbc -K $encrypted_7b5c925cc32c_key -iv $encrypted_7b5c925cc32c_iv -in nr-commons.keystore.enc -out nr-commons.keystore -d;
fi
- if [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then
openssl aes-256-cbc -K $encrypted_38ac1a5053f6_key -iv $encrypted_38ac1a5053f6_iv -in play.p12.enc -out play.p12 -d;
fi
notifications:
webhooks:
- https://wiki-commons.zulipchat.com/api/v1/external/travis?api_key=kn4a8YKNqHCBYp7EW2k463txMj35vReq&stream=travis-ci

View file

@ -1,375 +1,6 @@
# Wikimedia Commons for Android
## v6.0.2
### What's changed
* Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard
* Links in the "File usages" list are now clickable and will take you to the correct page.
* Titles for file usages are now clearer and easier to understand
* Bug fixes and stability improvements
## v6.0.1
### What's changed
* The app now supports Android 15 with an improved user interface
* Enhanced Nearby with robust and more reliable labels
* Bug fixes and stability improvements
## v5.6.1
### What's changed
* The app no longer uploads images to Wikidata if one exists already for a given item
* File usage displays correctly now
* No more infinite circular progress bar on nominating an image for deletion
* Enhanced location updates while using GPS
* Author/uploader names are now available in Media Details for Commons licensing compliance
* Improved usage of popups in Nearby
* Bug fixes and stability improvements
## v5.5.0
### What's changed
* Explore images will now be shown based on the map location and not at your current location
* Enhanced Wikidata feedback message
* Green labels in Explore map will no longer be hidden by other pins thumbnails
* Upload wizard's language drop-down now reflects the language used in the pin label
* Users can now pick only one image at a time while using the custom selector
* Bug fixes and stability improvements
## v5.4.1
### What's changed
* Custom picker now detects images that are already available on Commons
* Improve credit line in image list
* Show place cards with loaded names only in the Nearby list
* Fix the error that occurs while loading images in Explore
## v5.3.0
### What's changed
* Enable EmailAuth support
* Explore map images no longer show "Unknown"
* Fix crash when removing last two images of multiupload
* Mark ❌ for closed locations (P3999) in Nearby
* Fix two pin labels staying visible at the same time in Explore map
* Refactoring and minor UI improvements
## v5.2.0
v5.2.0 boasts several new functionalities like:
* A new refresh button lets you quickly reload the Nearby map
* Bookmarks now support categories
* Improved feedback and consistency in the user interface
* Bug fixes and performance improvements
### What's changed
* Implement "Refresh" button to clear the cache and reload the Nearby map.
* `CommonsApplication` migrate to kotlin & some lint fixes.
* Revert back to MainScope for database and UI updates and make database operations thread safe.
* Hide edit options for logged-out users in Explore screen.
* Introduced a button to delete the current folder in custom selector.
* Improve Unique File Name Search.
* Migration of several modules from Java to Kotlin.
* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins.
* Bug fixes and enhancement of Achievements screen.
* Show where file is being used on Commons and other wikis.
* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices.
* Make dialogs modal and always show the upload icon.
* Fix unintentional deletion of subfolders and non-images by custom selector.
* Bookmark categories.
* Add pull down to refresh in the Contributions screen.
* Fix race condition and lag when loading pin details, faster overlay management.
* Show cached pins in Nearby even when internet is unavailable
Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0).
## v5.1.2
### What's changed
* Fix the broken category search in the explore screen
## v5.1.1
### What's changed
* Use Android's new EXIF interface to mitigate security issues in old
EXIF interface.
* Make the icon that helps view the upload queue always visible as it ensures
that the queue accessible at all times.
## v5.1.0
### What's Changed
* Enhanced **upload queue management** in the Commons app for smoother, sequential
processing, clearer progress tracking, prevention of stuck or duplicate
uploads. As part of this improvement, the "Limited Connection mode" has been
removed.
* Added an option in "Nearby" feature enabling users to **provide feedback on
Wikidata items**. Users can report if an item 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
same category and depictions.
- Show Wikidata description on currently active Nearby pin to provide more useful information.
- Improve the visibility of map markers by dynamically adjusting their colors based on the app's
theme. The map markers will now appear lighter when the app is in dark mode and darker when the
app is in light mode. This change aims to enhance marker visibility and improve the overall user
experience.
- Added information on where user feedback is posted, helping users track existing feedback and
monitor their own submissions.
- Enhanced the edit location screen of the upload screen by centering the map on the picture's
location from metadata when editing, or on the device's GPS location if metadata is unavailable,
improving accuracy and user experience.
- Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a
recently uploaded image, enhancing clarity and user experience.
- Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user
experience by showing loading progress until the image is fully loaded.
- Fixed an issue where caption and description fields would intermittently disappear when using
voice input, ensuring text remains visible and stable across all entries.
- Fixed a crash that occurred when attempting to remove multiple instances of caption/description
fields after initially adding them.
- Improve the text in the prompt shown when skipping login to sound more natural.
- Modified feedback addition logic to append new sections at the bottom of the page, ensuring
auto-archiving of sections functions correctly on the feedback page.
- Resolved issue where the app failed to clear cookies upon logout.
## v5.0.1
Same as v5.0.0 except this fixes some R8 rules to ensure that the release
variants of the app work as intended.
## v5.0.0
### What's Changed
- Redesigned the map feature to **replace Mapbox with the osmdroid library**.
Key elements like pin visualization and user-centered display are still
included in this redesign. This is done to guard against possible misuse of
the Mapbox token and, more crucially, to keep the app from becoming dependent
on a service that charges for usage but offers a free tier.
With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org).
- Add the ability to **export locations of nearby missing pictures in GPX and
KML formats**. This allows users to browse the locations with desired radius
for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing
accessibility and offline functionality.
- **Limited the uploads via the custom image picker** to a maximum of 20.
- Added two menu choices for **transparent image backgrounds**, giving users the
option of either a black or white background, increasing adaptability to
various theme settings.
User customization option has been provided with the
ability to save background color selections permanently on a per image basis.
- Implemented functionality to **automatically resume uploads** that become
stuck due to app termination or device reboot.
- Added a **compass arrow in the Nearby banner** shown in the "Contributions"
screen to guide users towards the nearest item, thus providing the missing
directional cues. The arrow dynamically adjusts based on device rotation,
aligning with the calculated bearing towards the target location. Further,
the distance and direction are updated as the user moves.
- Implemented **voice input feature** for caption and description fields,
enabling users to dictate text directly into these fields.
- Improved various flows in the app to **redirect users to the login page** and
display a persistent message **if their session becomes invalid** due to a
password change, enhancing user guidance and security measures.
### Revamps and refactorings
- **Revamped initial upload screen layout and the description edit screen layout**
for enhanced user experience and ensuring better symmetry in the design.
- **Replaced Butterknife with ViewBinding** in various places of the app.
- Transferred essential code from **the redundant data-client module** to the
main Commons app code, enabling its integration and facilitating the removal
of the redundant module. Further, convert various parts of the code to Kotlin.
- **Revamped the various location permission flows** to ensure consistency for
the sake of a better user experience.
### Bug fixes and various changes
- Resolved an issue where paused uploads that were subsequently cancelled were
still being uploaded.
- Fixed an issue where some user information such as upload count were not
displayed in the "Contributions" and "Profile" screens.
- Fixed the long-standing broken *"Picture of the Day" widget* to restore its
usability.
- Resolved an issue where some categories were hidden at the top of Upload
Wizard suggestions.
- Resolved an issue where there was a grey empty screen at Upload wizard when
the app was denied the files permission.
- Implemented logic to bypass media in Peer Review if the current reviewer is
also the user who uploaded the media.
- Corrected arrow image behaviour in the first upload screen: now displays down
arrow when details card is fully visible, aligning with expected user
interaction.
- Updated app icon to improve visibility and recognition on F-Droid.
- Fixed issue causing all pictures to disappear and activity to reload fully in
the custom image selector after marking a picture as 'not for upload', now
ensuring only the selected picture is removed as expected.
What's listed here is only a subset of all the changes. Check the full-list of
the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0).
Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0)
for an exhaustive list of changes and the various contributors who contributed the same.
## v4.2.1
- Provide the ability to edit an image to losslessly rotate it while uploading
- Fix a bug in v4.2.0 where the nearby places were not loading
- Fix a bug where editing depictions was showing a progress bar indefinitely
- In the upload screen, use different map icons to indicate if image is being uploaded with location
metadata
- For nearby uploads, it is no longer possible to deselect the item's category and depiction
- The Mapbox account key used by the app has been changed
- Category search now shows exact matches without any discrepancies
- Various bug and crash fixes
## v4.2.0
- Dark mode colour improvements
- Enhancements done to address location metadata loss including the metadata loss that occurs in
latest Android versions
- Enhancements done to address the issue where uploads get stuck in queued state
- Fix the inability to upload via the in-app camera option
- Provide the ability to optionally include location metadata for in-app camera uploads in case the
device camera app does not provide location metadata
- Use geo location URL that works consistently across all map applications
- Fix crash when clicking on location target icon while trying to edit the location of an upload
- Fix crash that occurs randomly while returning to the app after leaving it in the background
- Fix crash in Sign up activity on Android version 5.0 and 5.1
- Android 13 compatibility changes
## v4.1.0
- Location of pictures uploaded via custom picture selector are now recognized
- Improvements to the custom picture selector
- Ensure the WLM pictures are associated with the correct templates for each year
- Only show pictures uploaded via app in peer review
- Improve the variety of images show in peer review
- Allow going to current location in location edit dialog while uploading a picture
- Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox
- Fixed various bugs
## v4.0.5
- Bumped min SDK to 29 to try and solve Google policy issue
- Reverted dialog
- Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published)
## v4.0.4
- Added dialog for Google's location policy
## v4.0.3
- Added "Report" button for Google UGC policy
## v4.0.2
- Fixed bug with wrong dates taken from EXIF
- Fixed various crashes
## v4.0.1
- Fixed bug with no browser found
- Updated Mapbox SDK to fix hamburger crash
## v4.0.0
- Added map showing nearby Commons pictures
- Added custom SPARQL queries
- Added user profiles
- Added custom picture selector
- Various bugfixes
- Updated target SDK to 30
## v3.1.1
- Optimized Nearby query
- Added Sweden's property for WLM 2021
- Added link to wiki explaining how to contribute to WLM through app
- Fixed various bugs and crashes
## v3.1.0
- Added Wiki Loves Monuments integration for WLM 2021
## v3.0.2
- Fixed crash when uploading high res image
- Fixed crash when viewing images in Explore
## v3.0.1
- Pre-fill desc in Nearby uploads with Wikidata item's label + description
- Improved ACRA crash reporting
- Fixed various crashes
## v3.0.0
- Added Structured Data to upload workflow, users can now add depicts
- Added Leaderboard in Achievements screen
- Added to-do system for images with no categories/descriptions or with associated Wikipedia articles that have no pictures
- Users can now modify and add categories to their uploads from the media details view
- New UI for main screen
- Limited connection mode added, users can now pause and resume uploads
## v2.13.1
- Added OpenStreetMap attribution
- Fixed various crashes
- Fixed SQLite error in Nearby map
- Fixed issue with Nearby uploads not being associated with Wikidata p18
## v2.13.0
## v2.13
- New media details UI, ability to zoom and pan around image
- Added suggestions for a place that needs photos if user uploads a photo that is near one of them
- Modifications and fixes to Nearby filters based on user feedback

View file

@ -7,9 +7,9 @@ Here's a gist of the guidelines,
1. Make separate commits for logically separate changes
2. Describe your changes well in the commit message
1. Describe your changes well in the commit message
The first line of the commit message should be a short description of what has
The first line of the commit message should be a short description of what has
changed. It is also good to prefix the first line with "area: " where the "area"
is a filename or identifier for the general area of the code being modified.
The body should provide a meaningful commit message.
@ -29,9 +29,9 @@ The body should provide a meaningful commit message.
and if needed, Git allows us to see who wrote something without needing
to add these tags (`git blame`)
2. Write tests for your code (if possible)
1. Write tests for your code (if possible)
3. Make sure the Wiki pages don't become stale by updating them (if needed)
1. Make sure the Wiki pages don't become stale by updating them (if needed)
### Further reading

View file

@ -47,12 +47,12 @@ their contribution to the product.
* Alicia Bendz
* Kaartic Sivaraam
* Vanshika Arora
* Seán Mac Gillicuddy
3rd party open source libraries used:
* Butterknife
* GSON
* Timber
* MapBox
3rd party open source apps from which significant code has been reused:
* Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia

36
ISSUE_TEMPLATE.md Normal file
View file

@ -0,0 +1,36 @@
**Summary:**
Summarize your issue in one sentence (what goes wrong, what did you expect to happen)
_Before creating an issue, please search the existing issues to see if a similar one has already been created. You can search issues by specific labels (e.g. `label:nearby`) or just by typing keywords into the search filter._
**Steps to reproduce:**
How can we reproduce the issue?
What did you expect the app to do, and what did you see instead?
**System logs:**
```
Add logcat files here (if possible).
Need help? See https://github.com/commons-app/apps-android-commons/wiki/Getting-app-logs-from-Android-Studio
```
**Device and Android version:**
What make and model device (e.g., Samsung J7) did you encounter this on?
What Android version (e.g., Android 4.0 Ice Cream Sandwich or Android 6.0 Marshmallow) are you running?
Is it the stock version from the manufacturer or a custom ROM ?
**Commons app version:**
You can find this information by going to the navigation drawer in the app and tapping 'About'. If you are building from our codebase instead of downloading the app, please also mention the branch and build variant (e.g. master and prodDebug).
**Screen-shots:**
Can be created by pressing the Volume Down and Power Button at the same time on Android 4.0 and higher.
**Would you like to work on the issue?**
Please let us know whether you want to fix the issue by yourself. If not, anyone can get the issue assigned to them.

View file

@ -1,21 +1,19 @@
# Wikimedia Commons Android app
![GitHub issue custom search](https://img.shields.io/github/issues-search?label=%22good%20first%20issue%22%20issues&query=repo%3Acommons-app%2Fapps-android-commons%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22)
[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain)
[![Build status](https://api.travis-ci.org/commons-app/apps-android-commons.svg?branch=master)](https://travis-ci.org/commons-app/apps-android-commons)
[![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)
The Wikimedia Commons Android app allows users to upload pictures from their Android phone/tablet to Wikimedia Commons. Download the app [here][1], or view our [website][2].
Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request! :-)
Initially started by the Wikimedia Foundation, this app is now maintained by grantees and volunteers of the Wikimedia community. Anyone is welcome to improve it, just choose among the [open issues][3] and send us a pull request :-)
<a href="https://f-droid.org/repository/browse/?fdid=fr.free.nrw.commons" target="_blank">
<img src="https://upload.wikimedia.org/wikipedia/commons/archive/9/96/20200131184248%21%22Get_it_on_F-droid%22_Badge.png" alt="Get it on F-Droid" height="90"/></a>
<img src="https://upload.wikimedia.org/wikipedia/commons/9/96/%22Get_it_on_F-droid%22_Badge.png" alt="Get it on F-Droid" height="90"/></a>
<a href="https://play.google.com/store/apps/details?id=fr.free.nrw.commons" target="_blank">
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="90"/></a>
## Documentation
Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike:
We try to have an extensive documentation at [our wiki here at Github][4]:
* [User Documentation][5]
* [Contributor Documentation][6]
@ -27,14 +25,13 @@ Our [documentation repository][4] contains extensive documentation for users, co
Thank you all for your work!
| [<img src="https://avatars.githubusercontent.com/u/3611199?v=4" width="100px;"/><br /><sub><b>misaochan</b></sub>](https://github.com/misaochan) | [<img src="https://avatars.githubusercontent.com/u/24829418?v=4" width="100px;"/><br /><sub><b>translatewiki</b></sub>](https://github.com/translatewiki) | [<img src="https://avatars.githubusercontent.com/u/3127881?v=4" width="100px;"/><br /><sub><b>neslihanturan</b></sub>](https://github.com/neslihanturan) | [<img src="https://avatars.githubusercontent.com/u/30430?v=4" width="100px;"/><br /><sub><b>yuvipanda</b></sub>](https://github.com/yuvipanda) | [<img src="https://avatars.githubusercontent.com/u/99590?v=4" width="100px;"/><br /><sub><b>nicolas-raoul</b></sub>](https://github.com/nicolas-raoul) |
| [<img src="https://avatars1.githubusercontent.com/u/3611199?v=4" width="100px;"/><br /><sub><b>misaochan</b></sub>](https://github.com/misaochan) | [<img src="https://avatars2.githubusercontent.com/u/30430?v=4" width="100px;"/><br /><sub><b>yuvipanda</b></sub>](https://github.com/yuvipanda) | [<img src="https://avatars1.githubusercontent.com/u/3127881?v=4" width="100px;"/><br /><sub><b>neslihanturan</b></sub>](https://github.com/neslihanturan) | [<img src="https://avatars2.githubusercontent.com/u/3069373?v=4" width="100px;"/><br /><sub><b>maskaravivek</b></sub>](https://github.com/maskaravivek) | [<img src="https://avatars3.githubusercontent.com/u/24829418?v=4" width="100px;"/><br /><sub><b>translatewiki</b></sub>](https://github.com/translatewiki) |
| :---: | :---: | :---: | :---: | :---: |
| [<img src="https://avatars.githubusercontent.com/u/407647?v=4" width="100px;"/><br /><sub><b>psh</b></sub>](https://github.com/psh) | [<img src="https://avatars.githubusercontent.com/u/4953590?v=4" width="100px;"/><br /><sub><b>domdomegg</b></sub>](https://github.com/domdomegg) | [<img src="https://avatars.githubusercontent.com/u/3069373?v=4" width="100px;"/><br /><sub><b>maskaravivek</b></sub>](https://github.com/maskaravivek) | [<img src="https://avatars.githubusercontent.com/u/30932899?v=4" width="100px;"/><br /><sub><b>madhurgupta10</b></sub>](https://github.com/madhurgupta10) | [<img src="https://avatars.githubusercontent.com/u/17375274?v=4" width="100px;"/><br /><sub><b>ashishkumar468</b></sub>](https://github.com/ashishkumar468) |
| [<img src="https://avatars.githubusercontent.com/u/103075?v=4" width="100px;"/><br /><sub><b>bvibber</b></sub>](https://github.com/bvibber) | [<img src="https://avatars.githubusercontent.com/u/10674?v=4" width="100px;"/><br /><sub><b>whym</b></sub>](https://github.com/whym) | [<img src="https://avatars.githubusercontent.com/u/10153800?v=4" width="100px;"/><br /><sub><b>akaita</b></sub>](https://github.com/akaita) | [<img src="https://avatars.githubusercontent.com/u/12448084?v=4" width="100px;"/><br /><sub><b>sivaraam</b></sub>](https://github.com/sivaraam) | [<img src="https://avatars.githubusercontent.com/u/6900601?v=4" width="100px;"/><br /><sub><b>veyndan</b></sub>](https://github.com/veyndan) |
| [<img src="https://avatars.githubusercontent.com/u/19607555?v=4" width="100px;"/><br /><sub><b>ujjwalagrawal17</b></sub>](https://github.com/ujjwalagrawal17) | [<img src="https://avatars.githubusercontent.com/u/3358282?v=4" width="100px;"/><br /><sub><b>macgills</b></sub>](https://github.com/macgills) | [<img src="https://avatars.githubusercontent.com/u/346271?v=4" width="100px;"/><br /><sub><b>amire80</b></sub>](https://github.com/amire80) | [<img src="https://avatars.githubusercontent.com/u/1682214?v=4" width="100px;"/><br /><sub><b>dbrant</b></sub>](https://github.com/dbrant) | [<img src="https://avatars.githubusercontent.com/u/34261945?v=4" width="100px;"/><br /><sub><b>vanshikaarora</b></sub>](https://github.com/vanshikaarora) |
| [<img src="https://avatars.githubusercontent.com/u/83745993?v=4" width="100px;"/><br /><sub><b>RitikaPahwa4444</b></sub>](https://github.com/RitikaPahwa4444) | [<img src="https://avatars.githubusercontent.com/u/71203077?v=4" width="100px;"/><br /><sub><b>Ayan-10</b></sub>](https://github.com/Ayan-10) | [<img src="https://avatars.githubusercontent.com/u/101377978?v=4" width="100px;"/><br /><sub><b>rohit9625</b></sub>](https://github.com/rohit9625) | [<img src="https://avatars.githubusercontent.com/u/126143257?v=4" width="100px;"/><br /><sub><b>shashankiitbhu</b></sub>](https://github.com/shashankiitbhu) | [<img src="https://avatars.githubusercontent.com/u/54663429?v=4" width="100px;"/><br /><sub><b>Pratham2305</b></sub>](https://github.com/Pratham2305) |
| [<img src="https://avatars.githubusercontent.com/u/111801812?v=4" width="100px;"/><br /><sub><b>parneet-guraya</b></sub>](https://github.com/parneet-guraya) | [<img src="https://avatars.githubusercontent.com/u/1345681?v=4" width="100px;"/><br /><sub><b>sandarumk</b></sub>](https://github.com/sandarumk) | [<img src="https://avatars.githubusercontent.com/u/29161745?v=4" width="100px;"/><br /><sub><b>tanvidadu</b></sub>](https://github.com/tanvidadu) | [<img src="https://avatars.githubusercontent.com/u/39745544?v=4" width="100px;"/><br /><sub><b>cypherop</b></sub>](https://github.com/cypherop) | [<img src="https://avatars.githubusercontent.com/u/65972015?v=4" width="100px;"/><br /><sub><b>Prince-kushwaha</b></sub>](https://github.com/Prince-kushwaha) |
| [<img src="https://avatars1.githubusercontent.com/u/99590?v=4" width="100px;"/><br /><sub><b>nicolas-raoul</b></sub>](https://github.com/nicolas-raoul) | [<img src="https://avatars0.githubusercontent.com/u/407647?v=4" width="100px;"/><br /><sub><b>psh</b></sub>](https://github.com/psh) | [<img src="https://avatars2.githubusercontent.com/u/103075?v=4" width="100px;"/><br /><sub><b>brion</b></sub>](https://github.com/brion) | [<img src="https://avatars3.githubusercontent.com/u/10674?v=4" width="100px;"/><br /><sub><b>whym</b></sub>](https://github.com/whym) | [<img src="https://avatars0.githubusercontent.com/u/4953590?v=4" width="100px;"/><br /><sub><b>domdomegg</b></sub>](https://github.com/domdomegg) |
| [<img src="https://avatars2.githubusercontent.com/u/10153800?v=4" width="100px;"/><br /><sub><b>akaita</b></sub>](https://github.com/akaita) | [<img src="https://avatars0.githubusercontent.com/u/6900601?v=4" width="100px;"/><br /><sub><b>veyndan</b></sub>](https://github.com/veyndan) | [<img src="https://avatars0.githubusercontent.com/u/19607555?v=4" width="100px;"/><br /><sub><b>ujjwalagrawal17</b></sub>](https://github.com/ujjwalagrawal17) | [<img src="https://avatars1.githubusercontent.com/u/1682214?v=4" width="100px;"/><br /><sub><b>dbrant</b></sub>](https://github.com/dbrant) | [<img src="https://avatars3.githubusercontent.com/u/1345681?v=4" width="100px;"/><br /><sub><b>sandarumk</b></sub>](https://github.com/sandarumk) |
| [<img src="https://avatars0.githubusercontent.com/u/6953323?v=4" width="100px;"/><br /><sub><b>tobias47n9e</b></sub>](https://github.com/tobias47n9e) | [<img src="https://avatars0.githubusercontent.com/u/29161745?v=4" width="100px;"/><br /><sub><b>tanvidadu</b></sub>](https://github.com/tanvidadu) | [<img src="https://avatars1.githubusercontent.com/u/25305892?v=4" width="100px;"/><br /><sub><b>hismaeel</b></sub>](https://github.com/hismaeel) | [<img src="https://avatars0.githubusercontent.com/u/12574756?v=4" width="100px;"/><br /><sub><b>tshradheya</b></sub>](https://github.com/tshradheya) | [<img src="https://avatars1.githubusercontent.com/u/27244688?v=4" width="100px;"/><br /><sub><b>diddypod</b></sub>](https://github.com/diddypod) |
| [<img src="https://avatars0.githubusercontent.com/u/32291277?v=4" width="100px;"/><br /><sub><b>prxjeen</b></sub>](https://github.com/prxjeen) | [<img src="https://avatars2.githubusercontent.com/u/3308769?v=4" width="100px;"/><br /><sub><b>addshore</b></sub>](https://github.com/addshore) | [<img src="https://avatars3.githubusercontent.com/u/20313518?v=4" width="100px;"/><br /><sub><b>knight-shade</b></sub>](https://github.com/knight-shade) | [<img src="https://avatars3.githubusercontent.com/u/17375274?v=4" width="100px;"/><br /><sub><b>ashishkumar468</b></sub>](https://github.com/ashishkumar468) | [<img src="https://avatars0.githubusercontent.com/u/210297?v=4" width="100px;"/><br /><sub><b>siebrand</b></sub>](https://github.com/siebrand) |
| [<img src="https://avatars0.githubusercontent.com/u/5329780?v=4" width="100px;"/><br /><sub><b>Bluesir9</b></sub>](https://github.com/Bluesir9) | [<img src="https://avatars1.githubusercontent.com/u/17095817?v=4" width="100px;"/><br /><sub><b>Jatin0312</b></sub>](https://github.com/Jatin0312) | [<img src="https://avatars3.githubusercontent.com/u/3415851?v=4" width="100px;"/><br /><sub><b>mashawan</b></sub>](https://github.com/mashawan) | [<img src="https://avatars3.githubusercontent.com/u/594179?v=4" width="100px;"/><br /><sub><b>ford-prefect</b></sub>](https://github.com/ford-prefect) | [<img src="https://avatars1.githubusercontent.com/u/21229885?v=4" width="100px;"/><br /><sub><b>seantnemann</b></sub>](https://github.com/seantnemann) |
.. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors).
@ -46,13 +43,13 @@ 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?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
[3]: https://github.com/commons-app/apps-android-commons/issues
[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation
[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation
[6]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-contributor-documentation
[7]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Volunteers-welcome!.md#volunteers-welcome
[8]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-developer-documentation
[9]: https://github.com/commons-app/commons-app-documentation/blob/master/android/Libraries-used.md#libraries-used
[4]: https://github.com/commons-app/apps-android-commons/wiki
[5]: https://github.com/commons-app/apps-android-commons/wiki#user-documentation
[6]: https://github.com/commons-app/apps-android-commons/wiki#contributor-documentation
[7]: https://github.com/commons-app/apps-android-commons/wiki/Volunteers-welcome%21
[8]: https://github.com/commons-app/apps-android-commons/wiki#developer-documentation
[9]: https://github.com/commons-app/apps-android-commons/wiki/Libraries-used
[10]: https://www.apache.org/licenses/LICENSE-2.0

298
app/build.gradle Normal file
View file

@ -0,0 +1,298 @@
plugins {
id 'com.github.triplet.play' version '2.7.2' apply false
}
apply from: '../gitutils.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: "com.hiya.jacoco-android"
apply from: 'quality.gradle'
def isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file('../play.p12').exists()
if(isRunningOnTravisAndIsNotPRBuild) {
apply plugin: 'com.github.triplet.play'
}
dependencies {
// Utils
implementation 'com.github.nicolas-raoul:Quadtree:ac16ea8035bf07'
implementation 'in.yuvi:http.fluent:1.3'
implementation 'com.google.code.gson:gson:2.8.5'
implementation 'com.squareup.okhttp3:okhttp:4.2.0'
implementation 'com.squareup.okio:okio:2.2.2'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.3'
implementation 'com.jakewharton.rxbinding2:rxbinding:2.1.1'
implementation 'com.jakewharton.rxbinding2:rxbinding-support-v4:2.1.1'
implementation 'com.jakewharton.rxbinding2:rxbinding-appcompat-v7:2.1.1'
implementation 'com.jakewharton.rxbinding2:rxbinding-design:2.1.1'
implementation 'com.facebook.fresco:fresco:1.13.0'
implementation 'org.apache.commons:commons-lang3:3.8.1'
implementation 'com.github.maskaravivek:wikimedia-android-data-client:v0.0.30'
// UI
implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
implementation 'com.github.chrisbanes:PhotoView:2.0.0'
implementation 'com.github.pedrovgs:renderers:3.3.3'
implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:8.6.2'
implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-localization-v8:0.11.0'
implementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-scalebar-v9:0.4.0'
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
implementation 'com.dinuscxj:circleprogressbar:1.1.1'
implementation 'com.karumi:dexter:5.0.0'
implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION"
kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
// Logging
implementation 'ch.acra:acra-dialog:5.3.0'
implementation 'ch.acra:acra-mail:5.3.0'
implementation 'org.slf4j:slf4j-api:1.7.25'
api('com.github.tony19:logback-android-classic:1.1.1-6') {
exclude group: 'com.google.android', module: 'android'
}
implementation "com.squareup.okhttp3:logging-interceptor:4.2.0"
// Dependency injector
implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION"
//Mocking
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
testImplementation 'org.mockito:mockito-inline:2.8.47'
testImplementation 'org.mockito:mockito-core:2.23.0'
testImplementation "org.powermock:powermock-module-junit4:2.0.0-beta.5"
testImplementation "org.powermock:powermock-api-mockito2:2.0.0-beta.5"
// Unit testing
testImplementation 'junit:junit:4.12'
testImplementation 'org.robolectric:robolectric:4.3'
testImplementation 'androidx.test:core:1.2.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.1'
testImplementation "org.powermock:powermock-module-junit4:2.0.0-beta.5"
testImplementation "org.powermock:powermock-api-mockito2:2.0.0-beta.5"
testImplementation 'org.mockito:mockito-core:2.23.0'
// Android testing
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.annotation:annotation:1.1.0'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.12.1'
androidTestUtil 'androidx.test:orchestrator:1.2.0'
// Debugging
debugImplementation "com.squareup.leakcanary:leakcanary-android:$LEAK_CANARY_VERSION"
releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
testImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$LEAK_CANARY_VERSION"
// Support libraries
implementation "com.google.android.material:material:1.1.0-alpha04"
implementation "androidx.browser:browser:1.0.0"
implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "androidx.exifinterface:exifinterface:1.0.0"
//swipe_layout
implementation 'com.daimajia.swipelayout:library:1.2.0@aar'
//Room
implementation "androidx.room:room-runtime:$ROOM_VERSION"
kapt "androidx.room:room-compiler:$ROOM_VERSION" // For Kotlin use kapt instead of annotationProcessor
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
implementation "androidx.room:room-rxjava2:$ROOM_VERSION"
testImplementation "androidx.arch.core:core-testing:2.1.0"
// Pref
// Java language implementation
implementation "androidx.preference:preference:$PREFERENCE_VERSION"
// Kotlin
implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION"
}
android {
compileSdkVersion 28
buildToolsVersion "28.0.3"
defaultConfig {
//applicationId 'fr.free.nrw.commons'
versionCode 709
versionName '2.13'
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
minSdkVersion 19
targetSdkVersion 28
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
vectorDrawables.useSupportLibrary = true
}
packagingOptions {
exclude 'META-INF/androidx.*'
exclude 'META-INF/proguard/androidx-annotations.pro'
}
testOptions {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
jvmArgs '-noverify'
}
}
sourceSets {
// use kotlin only in tests (for now)
test.java.srcDirs += 'src/test/kotlin'
// use main assets and resources in test
test.assets.srcDirs += 'src/main/assets'
test.resources.srcDirs += 'src/main/resoures'
}
signingConfigs {
release
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt'
if(isRunningOnTravisAndIsNotPRBuild) {
signingConfig signingConfigs.release
}
}
debug {
minifyEnabled true
testCoverageEnabled project.hasProperty('coverage')
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt'
versionNameSuffix "-debug-" + getBranchName()
}
}
if (isRunningOnTravisAndIsNotPRBuild) {
// configure keystore based on env vars in Travis for automated alpha builds
signingConfigs.release.storeFile = file("../nr-commons.keystore")
signingConfigs.release.storePassword = System.getenv("keystore_password")
signingConfigs.release.keyAlias = System.getenv("key_alias")
signingConfigs.release.keyPassword = System.getenv("key_password")
}
configurations.all {
resolutionStrategy.force 'androidx.annotation:annotation:1.0.2'
}
flavorDimensions 'tier'
productFlavors {
prod {
applicationId 'fr.free.nrw.commons'
buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\""
buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\""
buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\""
buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\""
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\""
buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\""
buildConfigField "String", "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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\""
buildConfigField "String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\""
buildConfigField "String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\""
buildConfigField "String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\""
buildConfigField "String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\""
buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\""
buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\""
buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
dimension 'tier'
}
beta {
applicationId 'fr.free.nrw.commons.beta'
// What values do we need to hit the BETA versions of the site / api ?
buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\""
buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\""
buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\""
buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\""
buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\""
buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\""
buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\""
buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""
buildConfigField "String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\""
buildConfigField "String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\""
buildConfigField "String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\""
buildConfigField "String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\""
buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\""
buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\""
buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\""
dimension 'tier'
}
}
lintOptions {
disable 'MissingTranslation'
disable 'ExtraTranslation'
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildToolsVersion buildToolsVersion
}
if(isRunningOnTravisAndIsNotPRBuild) {
play {
track = "alpha"
userFraction = 1
serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
serviceAccountCredentials = file("../play.p12")
resolutionStrategy = "auto"
outputProcessor { // this: ApkVariantOutput
versionNameOverride = "$versionNameOverride.$versionCode"
}
}
}
androidExtensions {
experimental = true
}

View file

@ -1,447 +0,0 @@
import java.util.Properties
import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize)
}
apply(from = "$rootDir/jacoco.gradle")
val isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file("../play.p12").exists()
if (isRunningOnTravisAndIsNotPRBuild) {
apply(plugin = "com.github.triplet.play")
}
android {
namespace = "fr.free.nrw.commons"
compileSdk = 35
defaultConfig {
applicationId = "fr.free.nrw.commons"
minSdk = 21
targetSdk = 35
versionCode = 1059
versionName = "6.1.0"
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
multiDexEnabled = true
vectorDrawables {
useSupportLibrary = true
}
}
sourceSets {
getByName("test") {
// Use kotlin only in tests (for now)
java.srcDirs("src/test/kotlin")
// Use main assets and resources in test
assets.srcDirs("src/main/assets")
resources.srcDirs("src/main/resources")
}
}
signingConfigs {
create("release") {
// Configure keystore based on env vars in Travis for automated alpha builds
if(isRunningOnTravisAndIsNotPRBuild) {
storeFile = file("../nr-commons.keystore")
storePassword = System.getenv("keystore_password")
keyAlias = System.getenv("key_alias")
keyPassword = System.getenv("key_password")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
testProguardFile("test-proguard-rules.txt")
signingConfig = signingConfigs.getByName("debug")
if (isRunningOnTravisAndIsNotPRBuild) {
signingConfig = signingConfigs.getByName("release")
}
}
debug {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.txt")
testProguardFile("test-proguard-rules.txt")
versionNameSuffix = "-debug-" + getBranchName()
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
}
}
configurations.all {
resolutionStrategy {
force("androidx.annotation:annotation:1.1.0")
force("com.jakewharton.timber:timber:4.7.1")
force("androidx.fragment:fragment:1.3.6")
}
exclude(module = "okhttp-ws")
}
flavorDimensions += "tier"
productFlavors {
create("prod") {
dimension = "tier"
applicationId = "fr.free.nrw.commons"
buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"")
buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"")
buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"")
buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"")
buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.org\"")
buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"")
buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"")
buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"")
buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"")
buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"")
buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"")
buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"")
buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.categories.contentprovider\"")
buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.explore.recentsearches.contentprovider\"")
buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.recentlanguages.contentprovider\"")
buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.contentprovider\"")
buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.locations.contentprovider\"")
buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.bookmarks.items.contentprovider\"")
buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
buildConfigField("String", "DEPICTS_PROPERTY", "\"P180\"")
buildConfigField("String", "CREATOR_PROPERTY", "\"P170\"")
}
create("beta") {
dimension = "tier"
applicationId = "fr.free.nrw.commons.beta"
// What values do we need to hit the BETA versions of the site / api ?
buildConfigField("String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"")
buildConfigField("String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"")
buildConfigField("String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"")
buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
buildConfigField("String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"")
buildConfigField("String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"")
buildConfigField("String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"")
buildConfigField("String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"")
buildConfigField("String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"")
buildConfigField("String", "WIKIDATA_URL", "\"https://www.wikidata.org\"")
buildConfigField("String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"")
buildConfigField("String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"")
buildConfigField("String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"")
buildConfigField("String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"")
buildConfigField("String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"")
buildConfigField("String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"")
buildConfigField("String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"")
buildConfigField("String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"")
buildConfigField("String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"")
buildConfigField("String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"")
buildConfigField("String", "CATEGORY_AUTHORITY", "\"fr.free.nrw.commons.beta.categories.contentprovider\"")
buildConfigField("String", "RECENT_SEARCH_AUTHORITY", "\"fr.free.nrw.commons.beta.explore.recentsearches.contentprovider\"")
buildConfigField("String", "RECENT_LANGUAGE_AUTHORITY", "\"fr.free.nrw.commons.beta.recentlanguages.contentprovider\"")
buildConfigField("String", "BOOKMARK_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.contentprovider\"")
buildConfigField("String", "BOOKMARK_LOCATIONS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.locations.contentprovider\"")
buildConfigField("String", "BOOKMARK_ITEMS_AUTHORITY", "\"fr.free.nrw.commons.beta.bookmarks.items.contentprovider\"")
buildConfigField("String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"")
buildConfigField("String", "TEST_USERNAME", "\"" + getTestUserName() + "\"")
buildConfigField("String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"")
buildConfigField("String", "DEPICTS_PROPERTY", "\"P245962\"")
buildConfigField("String", "CREATOR_PROPERTY", "\"P253075\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
}
buildToolsVersion = buildToolsVersion
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
jniLibs {
excludes += listOf("META-INF/androidx.*")
}
resources {
excludes += listOf(
"META-INF/androidx.*",
"META-INF/proguard/androidx-annotations.pro",
"/META-INF/LICENSE.md",
"/META-INF/LICENSE-notice.md"
)
}
}
testOptions {
animationsDisabled = true
unitTests {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
unitTests.all {
it.jvmArgs("-noverify")
}
}
lint {
abortOnError = false
disable += listOf("MissingTranslation", "ExtraTranslation")
}
}
dependencies {
// Utils
implementation(libs.gson)
implementation(libs.okhttp)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.retrofit.adapter.rxjava)
implementation(libs.rxandroid)
implementation(libs.rxjava)
implementation(libs.rxbinding)
implementation(libs.rxbinding.appcompat)
implementation(libs.facebook.fresco)
implementation(libs.facebook.fresco.middleware)
implementation(libs.apache.commons.lang3)
// UI
implementation("${libs.viewpagerindicator.library.get()}@aar")
implementation(libs.photoview)
implementation(libs.android.sdk)
implementation(libs.android.plugin.scalebar)
implementation(libs.timber)
implementation(libs.android.material)
implementation(libs.dexter)
// Jetpack Compose
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.ui.viewbinding)
implementation(libs.androidx.material3)
implementation(libs.androidx.foundation)
implementation(libs.androidx.foundation.layout)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.adapterdelegates4.kotlin.dsl.viewbinding)
implementation(libs.adapterdelegates4.pagination)
implementation(libs.androidx.paging.runtime.ktx)
testImplementation(libs.androidx.paging.common.ktx)
implementation(libs.androidx.paging.rxjava2.ktx)
implementation(libs.androidx.recyclerview)
// Logging
implementation(libs.acra.dialog)
implementation(libs.acra.mail)
implementation(libs.slf4j.api)
implementation(libs.logback.android.classic) {
exclude(group = "com.google.android", module = "android")
}
implementation(libs.logging.interceptor)
// Dependency injector
implementation(libs.dagger.android)
implementation(libs.dagger.android.support)
kapt(libs.dagger.android.processor)
kapt(libs.dagger.compiler)
annotationProcessor(libs.dagger.android.processor)
implementation(libs.kotlin.reflect)
//Mocking
testImplementation(libs.mockito.kotlin)
testImplementation(libs.mockito.core)
testImplementation(libs.powermock.module.junit)
testImplementation(libs.powermock.api.mockito)
testImplementation(libs.mockk)
// Unit testing
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.runner)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.test.rules)
testImplementation(libs.mockwebserver)
testImplementation(libs.livedata.testing.ktx)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testImplementation(libs.soloader)
testImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.androidx.fragment.testing)
testImplementation(libs.commons.io)
// Android testing
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.intents)
androidTestImplementation(libs.androidx.espresso.contrib)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.annotation)
androidTestImplementation(libs.mockwebserver)
androidTestImplementation(libs.androidx.uiautomator)
// Debugging
debugImplementation(libs.leakcanary.android)
// Support libraries
implementation(libs.androidx.browser)
implementation(libs.androidx.cardview)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.exifinterface)
implementation(libs.recyclerview.fastscroll)
//swipe_layout
implementation(libs.swipelayout.library)
//Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.rxjava)
kapt(libs.androidx.room.compiler)
// Preferences
implementation(libs.androidx.preference)
implementation(libs.androidx.preference.ktx)
//Android Media
implementation(libs.juanitobananas.androidDmediaUtil)
implementation(libs.androidx.multidex)
// Kotlin + coroutines
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.work.runtime)
implementation(libs.kotlinx.coroutines.rx2)
testImplementation(libs.androidx.work.testing)
//Glide
implementation(libs.glide)
annotationProcessor(libs.glide.compiler)
kaptTest(libs.androidx.databinding.compiler)
kaptAndroidTest(libs.androidx.databinding.compiler)
implementation(libs.coordinates2country.android) {
exclude(group = "com.google.android", module = "android")
}
//OSMDroid
implementation(libs.osmdroid.android)
constraints {
implementation(libs.kotlin.stdlib.jdk7) {
because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
}
implementation(libs.kotlin.stdlib.jdk8) {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
}
}
}
tasks.register<Exec>("disableAnimations") {
val adb = "${System.getenv("ANDROID_HOME")}/platform-tools/adb"
commandLine(adb, "shell", "settings", "put", "global", "window_animation_scale", "0")
commandLine(adb, "shell", "settings", "put", "global", "transition_animation_scale", "0")
commandLine(adb, "shell", "settings", "put", "global", "animator_duration_scale", "0")
}
project.gradle.taskGraph.whenReady {
val connectedBetaDebugAndroidTest = tasks.named("connectedBetaDebugAndroidTest")
val connectedProdDebugAndroidTest = tasks.named("connectedProdDebugAndroidTest")
connectedBetaDebugAndroidTest.configure {
dependsOn("disableAnimations")
}
connectedProdDebugAndroidTest.configure {
dependsOn("disableAnimations")
}
}
fun getTestUserName(): String? {
val propFile = rootProject.file("./local.properties")
val properties = Properties()
propFile.inputStream().use { properties.load(it) }
return properties.getProperty("TEST_USER_NAME")
}
fun getTestPassword(): String? {
val propFile = rootProject.file("./local.properties")
val properties = Properties()
propFile.inputStream().use { properties.load(it) }
return properties.getProperty("TEST_USER_PASSWORD")
}
if (isRunningOnTravisAndIsNotPRBuild) {
configure<com.github.triplet.gradle.play.PlayPublisherExtension> {
track = "alpha"
userFraction = 1.0
serviceAccountEmail = System.getenv("SERVICE_ACCOUNT_NAME")
serviceAccountCredentials = file("../play.p12")
resolutionStrategy = "auto"
outputProcessor { // this: ApkVariantOutput
versionNameOverride = "$versionNameOverride.$versionCode"
}
}
}
fun getBuildVersion(): String? {
return try {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "rev-parse", "--short", "HEAD")
standardOutput = stdout
}
stdout.toString().trim()
} catch (e: Exception) {
null
}
}
fun getBranchName(): String? {
return try {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
standardOutput = stdout
}
stdout.toString().trim()
} catch (e: Exception) {
null
}
}

View file

@ -31,17 +31,6 @@
-keepattributes Signature
# Retain declared checked exceptions for use by a Proxy instance.
-keepattributes Exceptions
# Note: The model package right now seems to include some other classes that
# are not used for serialization / deserialization over Gson. Hopefully
# that's not a problem since it only prevents R8 from avoiding trimming
# of few more classes.
-keepclasseswithmembers class fr.free.nrw.commons.*.model.** { *; }
-keepclasseswithmembers class fr.free.nrw.commons.actions.** { *; }
-keepclasseswithmembers class fr.free.nrw.commons.auth.csrf.** { *; }
-keepclasseswithmembers class fr.free.nrw.commons.auth.login.** { *; }
-keepclasseswithmembers class fr.free.nrw.commons.wikidata.mwapi.** { *; }
# --- /Retrofit ---
# --- OkHttp + Okio ---
@ -66,9 +55,6 @@
# Application classes that will be serialized/deserialized over Gson
-keep class com.google.gson.examples.android.model.** { *; }
# Prevent R8 from obfuscating project classes used by Gson for parsing
-keep class fr.free.nrw.commons.fileusages.** { *; }
# Prevent proguard from stripping interface information from TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * implements com.google.gson.TypeAdapterFactory

45
app/quality.gradle Normal file
View file

@ -0,0 +1,45 @@
apply plugin: 'checkstyle'
apply plugin: 'pmd'
check.dependsOn 'checkstyle', 'pmd'
checkstyle {
toolVersion = '7.5.1'
}
task checkstyle(type: Checkstyle) {
configFile file("${project.rootDir}/script/style/checkstyle.xml")
source 'src'
include '**/*.java'
exclude '**/gen/**'
classpath = files()
reports {
html {
enabled true
destination file("${project.buildDir}/reports/checkstyle/checkstyle.html")
}
}
}
task pmd(type: Pmd) {
ignoreFailures = true
ruleSetFiles = files("${project.rootDir}/script/style/ruleset.xml")
ruleSets = []
source 'src'
include '**/*.java'
exclude '**/gen/**'
reports {
xml.enabled = false
html.enabled = true
xml {
destination file("${project.buildDir}/reports/pmd/pmd.xml")
}
html {
destination file("${project.buildDir}/reports/pmd/pmd.html")
}
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.test.InstrumentationRegistry
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
@ -11,13 +12,10 @@ import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.utils.ConfigUtils
import org.hamcrest.CoreMatchers
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -28,122 +26,82 @@ class AboutActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
Intents.init()
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@After
fun cleanUp() {
Intents.release()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@Test
fun testBuildNumber() {
Espresso
.onView(ViewMatchers.withId(R.id.about_version))
.check(
ViewAssertions.matches(
withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()),
),
)
Espresso.onView(ViewMatchers.withId(R.id.about_version))
.check(ViewAssertions.matches(withText(ConfigUtils.getVersionNameWithSha(getApplicationContext()))))
}
@Test
fun testLaunchWebsite() {
Espresso.onView(ViewMatchers.withId(R.id.website_launch_icon)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.WEBSITE_URL),
),
)
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.WEBSITE_URL)))
}
@Test
fun testLaunchFacebook() {
Espresso.onView(ViewMatchers.withId(R.id.facebook_launch_icon)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.anyOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME),
),
)
Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME)))
}
@Test
fun testLaunchGithub() {
Espresso.onView(ViewMatchers.withId(R.id.github_launch_icon)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.GITHUB_REPO_URL),
),
)
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.GITHUB_REPO_URL)))
}
@Test
fun testLaunchRateUs() {
val appPackageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
Espresso.onView(ViewMatchers.withId(R.id.about_rate_us)).perform(ViewActions.click())
Intents.intended(IntentMatchers.hasAction(Intent.ACTION_VIEW))
Intents.intended(CoreMatchers.anyOf(IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName"),
IntentMatchers.hasData("${Urls.PLAY_STORE_URL_PREFIX}$appPackageName")))
}
@Test
fun testLaunchAboutPrivacyPolicy() {
Espresso.onView(ViewMatchers.withId(R.id.about_privacy_policy)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL),
),
)
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL)))
}
@Test
fun testLaunchTranslate() {
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"),
),
)
val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0]
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode")))
}
@Test
fun testLaunchAboutCredits() {
Espresso.onView(ViewMatchers.withId(R.id.about_credits)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.CREDITS_URL),
),
)
}
@Test
fun testLaunchUserGuide() {
Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.USER_GUIDE_URL),
),
)
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.CREDITS_URL)))
}
@Test
fun testLaunchAboutFaq() {
Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FAQ_URL),
),
)
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FAQ_URL)))
}
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}

View file

@ -0,0 +1,37 @@
package fr.free.nrw.commons
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.filters.MediumTest
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.achievements.AchievementsActivity
import fr.free.nrw.commons.auth.LoginActivity
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AchievementsActivityTest {
@get:Rule
var activityRule = IntentsTestRule(LoginActivity::class.java)
@Before
fun setup() {
UITestHelper.skipWelcome()
UITestHelper.loginUser()
}
@Test
fun testAchievements() {
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
onView(withId(R.id.user_icon)).perform(click())
Intents.intended(hasComponent(AchievementsActivity::class.java.name))
}
}

View file

@ -1,19 +1,19 @@
package fr.free.nrw.commons
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import fr.free.nrw.commons.review.ReviewActivity
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.category.CategoryImagesActivity
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReviewActivityTest {
class CategoryImagesActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java)
var activityRule = ActivityTestRule(CategoryImagesActivity::class.java)
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}
}

View file

@ -0,0 +1,19 @@
package fr.free.nrw.commons
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import fr.free.nrw.commons.explore.categories.ExploreActivity
@RunWith(AndroidJUnit4::class)
class ExploreActivityTest {
@get:Rule
var activityRule = ActivityTestRule(ExploreActivity::class.java)
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}

View file

@ -8,17 +8,15 @@ import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SignupActivity
import fr.free.nrw.commons.contributions.MainActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -29,41 +27,33 @@ class LoginActivityTest {
@get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java)
private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
Intents.init()
try {
Intents.init()
} catch (ex: IllegalStateException) {
}
UITestHelper.skipWelcome()
intending(not(isInternal())).respondWith(ActivityResult(Activity.RESULT_OK, null))
}
@After
fun cleanUp() {
Intents.release()
@Test
fun testLogin() {
UITestHelper.loginUser()
Intents.intended(hasComponent(MainActivity::class.java.name))
}
@Test
fun testForgotPassword() {
Espresso.onView(ViewMatchers.withId(R.id.forgot_password)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL),
),
)
}
@Test
fun testSignupButton() {
Espresso.onView(ViewMatchers.withId(R.id.sign_up_button)).perform(ViewActions.click())
Intents.intended(IntentMatchers.hasComponent(SignupActivity::class.java.name))
UITestHelper.sleep(3000)
Espresso.onView(ViewMatchers.withId(R.id.forgot_password))
.perform(ViewActions.click())
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL)));
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}
}

View file

@ -1,214 +1,19 @@
package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import com.google.gson.Gson
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.notification.NotificationActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.Matchers
import org.junit.After
import org.junit.Before
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.contributions.MainActivity
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
@get:Rule
var mGrantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
"android.permission.ACCESS_FINE_LOCATION",
)
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private lateinit var defaultKvStore: JsonKvStore
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
UITestHelper.loginUser()
UITestHelper.skipWelcome()
Intents.init()
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
val context = InstrumentationRegistry.getInstrumentation().targetContext
val storeName = context.packageName + "_preferences"
defaultKvStore = JsonKvStore(context, storeName, Gson())
}
@After
fun cleanUp() {
Intents.release()
}
var activityRule = ActivityTestRule(MainActivity::class.java)
@Test
fun testNearby() {
Espresso
.onView(
Matchers.allOf(
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
1,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
Espresso
.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed()))
UITestHelper.sleep(10000)
val actionMenuItemView2 =
Espresso.onView(
Matchers.allOf(
ViewMatchers.withId(R.id.list_sheet),
ViewMatchers.withContentDescription("List"),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.toolbar),
1,
),
0,
),
ViewMatchers.isDisplayed(),
),
)
actionMenuItemView2.perform(ViewActions.click())
UITestHelper.sleep(1000)
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
@Test
fun testExplore() {
Espresso
.onView(
Matchers.allOf(
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
2,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
Espresso
.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed()))
UITestHelper.sleep(1000)
}
@Test
fun testContributions() {
Espresso
.onView(
Matchers.allOf(
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
0,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
Espresso
.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed()))
Espresso
.onView(
Matchers.allOf(
ViewMatchers.withId(R.id.contributionImage),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.contributionsList),
0,
),
1,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
val actionMenuItemView =
Espresso.onView(
Matchers.allOf(
ViewMatchers.withId(R.id.menu_bookmark_current_image),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.toolbar),
1,
),
0,
),
ViewMatchers.isDisplayed(),
),
)
actionMenuItemView.perform(ViewActions.click())
UITestHelper.sleep(3000)
}
@Test
fun testBookmarks() {
Espresso
.onView(
Matchers.allOf(
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
3,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
UITestHelper.sleep(1000)
}
@Test
fun testNotifications() {
Espresso
.onView(
Matchers.allOf(
ViewMatchers.withId(R.id.notifications),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.toolbar),
1,
),
1,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name))
Espresso.pressBack()
UITestHelper.sleep(1000)
}
}
}

View file

@ -0,0 +1,55 @@
package fr.free.nrw.commons
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.NavigationViewActions
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class NavigationBaseActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
/**
* Goes through all the activities in the app and checks we don't crash
* NB: This is not realistic if we're not logged in; we can access 'home', 'notifications', 'settings' and 'achievements' which we wouldn't otherwise be able to.
*/
@Test
fun goThroughNavigationBaseActivityActivities() {
// Home
openNavigationDrawerAndNavigateTo(R.id.action_home)
// Explore
openNavigationDrawerAndNavigateTo(R.id.action_explore)
// Bookmarks
openNavigationDrawerAndNavigateTo(R.id.action_bookmarks)
// About
openNavigationDrawerAndNavigateTo(R.id.action_about)
// Settings
openNavigationDrawerAndNavigateTo(R.id.action_settings)
// Achievements
openNavigationDrawerAndNavigateTo(R.id.action_login)
}
private fun openNavigationDrawerAndNavigateTo(menuItemId: Int) {
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
UITestHelper.sleep(500)
onView(withId(R.id.navigation_view)).perform(NavigationViewActions.navigateTo(menuItemId))
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}

View file

@ -1,67 +0,0 @@
package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.profile.ProfileActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.Matchers
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProfileActivityTest {
@get:Rule
var activityRule = IntentsTestRule(LoginActivity::class.java)
private val device: UiDevice = UiDevice.getInstance(getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
UITestHelper.loginUser()
UITestHelper.skipWelcome()
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@Test
fun testProfile() {
onView(
Matchers.allOf(
ViewMatchers.withContentDescription("More"),
childAtPosition(
childAtPosition(
withId(R.id.fragment_main_nav_tab_layout),
0,
),
4,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
onView(Matchers.allOf(withId(R.id.more_profile))).perform(
ViewActions.scrollTo(),
ViewActions.click(),
)
device.swipe(1033, 1346, 531, 1346, 20)
UITestHelper.sleep(5000)
Intents.intended(hasComponent(ProfileActivity::class.java.name))
}
}

View file

@ -1,59 +1,19 @@
package fr.free.nrw.commons
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.explore.SearchActivity
import org.hamcrest.Matchers
import org.junit.Before
import androidx.test.runner.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import fr.free.nrw.commons.explore.SearchActivity
@RunWith(AndroidJUnit4::class)
class SearchActivityTest {
@get:Rule
var activityRule = ActivityTestRule(SearchActivity::class.java)
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
}
@Test
fun exploreActivityTest() {
val searchAutoComplete =
Espresso.onView(
Matchers.allOf(
UITestHelper.childAtPosition(
Matchers.allOf(
ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
UITestHelper.childAtPosition(
ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")),
1,
),
),
0,
),
ViewMatchers.isDisplayed(),
),
)
searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard())
UITestHelper.sleep(5000)
device.swipe(1000, 1400, 500, 1400, 20)
device.swipe(800, 1400, 600, 1400, 20)
device.swipe(800, 1400, 600, 1400, 20)
device.swipe(800, 1400, 600, 1400, 20)
device.swipe(800, 1400, 600, 1400, 20)
device.swipe(800, 1400, 600, 1400, 20)
UITestHelper.sleep(1000)
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}
}

View file

@ -1,65 +0,0 @@
package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.settings.SettingsActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.Matchers
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SettingsActivityLoggedInTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
UITestHelper.loginUser()
UITestHelper.skipWelcome()
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@Test
fun testSettings() {
Espresso
.onView(
Matchers.allOf(
ViewMatchers.withContentDescription("More"),
UITestHelper.childAtPosition(
UITestHelper.childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
4,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform(
ViewActions.scrollTo(),
ViewActions.click(),
)
Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name))
UITestHelper.sleep(1000)
}
}

View file

@ -1,26 +1,28 @@
package fr.free.nrw.commons
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.matcher.PreferenceMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import androidx.test.runner.AndroidJUnit4
import com.google.gson.Gson
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.settings.SettingsActivity
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Matchers.allOf
import org.hamcrest.core.IsNot.not
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class SettingsActivityTest {
private lateinit var defaultKvStore: JsonKvStore
@ -28,39 +30,125 @@ class SettingsActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(SettingsActivity::class.java)
private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
val context = InstrumentationRegistry.getInstrumentation().targetContext
val storeName = context.packageName + "_preferences"
defaultKvStore = JsonKvStore(context, storeName, Gson())
}
@Test
fun setRecentUploadLimitTo123() {
// Open "Use external storage" preference
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.perform(click())
// Try setting it to 123
Espresso.onView(withId(android.R.id.edit))
.perform(replaceText("123"))
// Click "OK"
Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
.perform(click())
// Check setting set to 123 in SharedPreferences
assertEquals(
123,
defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
)
// Check displaying 123 in summary text
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.onChildView(withId(android.R.id.summary))
.check(matches(withText("123")))
}
@Test
fun setRecentUploadLimitTo0() {
// Open "Use external storage" preference
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.perform(click())
// Try setting it to 0
Espresso.onView(withId(android.R.id.edit))
.perform(replaceText("0"))
// Click "OK"
Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
.perform(click())
// Check setting set to 100 in SharedPreferences
assertEquals(
100,
defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
)
// Check displaying 100 in summary text
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.onChildView(withId(android.R.id.summary))
.check(matches(withText("100")))
}
@Test
fun setRecentUploadLimitTo700() {
// Open "Use external storage" preference
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.perform(click())
// Try setting it to 700
Espresso.onView(withId(android.R.id.edit))
.perform(replaceText("700"))
// Click "OK"
Espresso.onView(allOf(withId(android.R.id.button1), withText("OK")))
.perform(click())
// Check setting set to 500 in SharedPreferences
assertEquals(
500,
defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 0).toLong()
)
// Check displaying 100 in summary text
Espresso.onData(PreferenceMatchers.withKey("uploads"))
.inAdapterView(withId(android.R.id.list))
.onChildView(withId(android.R.id.summary))
.check(matches(withText("500")))
}
@Test
fun useAuthorNameTogglesOn() {
// Turn on "Use author name" preference if currently off
if (!defaultKvStore.getBoolean("useAuthorName", false)) {
Espresso
.onView(
allOf(
withId(R.id.recycler_view),
childAtPosition(withId(android.R.id.list_container), 0),
),
).perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()),
)
Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
.inAdapterView(withId(android.R.id.list))
.perform(click())
}
// Check authorName preference is enabled
Espresso
.onView(
allOf(
withId(R.id.recycler_view),
childAtPosition(withId(android.R.id.list_container), 0),
),
).check(matches(isEnabled()))
Espresso.onData(PreferenceMatchers.withKey("authorName"))
.inAdapterView(withId(android.R.id.list))
.check(matches(isEnabled()))
}
@Test
fun useAuthorNameTogglesOff() {
// Turn off "Use external storage" preference if currently on
if (defaultKvStore.getBoolean("useAuthorName", false)) {
Espresso.onData(PreferenceMatchers.withKey("useAuthorName"))
.inAdapterView(withId(android.R.id.list))
.perform(click())
}
// Check authorName preference is enabled
Espresso.onData(PreferenceMatchers.withKey("authorName"))
.inAdapterView(withId(android.R.id.list))
.check(matches(not(isEnabled())))
}
@Test

View file

@ -0,0 +1,50 @@
package fr.free.nrw.commons
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SignupActivity
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SignupTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
@Before
fun setup() {
UITestHelper.skipWelcome()
}
@Test
fun testSignupButton() {
try {
Intents.init()
} catch (ex: IllegalStateException) {
}
UITestHelper.sleep(3000)
Espresso.onView(withId(R.id.sign_up_button))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(click())
intended(hasComponent(SignupActivity::class.java.name))
Intents.release()
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}

View file

@ -2,8 +2,7 @@ package fr.free.nrw.commons
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.View
import android.view.ViewGroup
import androidx.test.espresso.Espresso.closeSoftKeyboard
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions
@ -13,141 +12,36 @@ import org.apache.commons.lang3.StringUtils
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import timber.log.Timber
class UITestHelper {
companion object {
fun skipWelcome() {
try {
onView(ViewMatchers.withId(R.id.button_ok))
.perform(ViewActions.click())
// Skip tutorial
//Skip tutorial
onView(ViewMatchers.withId(R.id.finishTutorialButton))
.perform(ViewActions.click())
} catch (ignored: NoMatchingViewException) {
}
}
fun skipLogin() {
try {
// Skip Login
val htmlTextView =
onView(
Matchers.allOf(
ViewMatchers.withId(R.id.skip_login),
ViewMatchers.withText("Skip"),
ViewMatchers.isDisplayed(),
),
)
htmlTextView.perform(ViewActions.click())
val appCompatButton =
onView(
Matchers.allOf(
ViewMatchers.withId(android.R.id.button1),
ViewMatchers.withText("Yes"),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.buttonPanel),
0,
),
3,
),
),
)
appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click())
.perform(ViewActions.click())
} catch (ignored: NoMatchingViewException) {
}
}
fun loginUser() {
try {
// Perform Login
//Perform Login
sleep(3000)
onView(ViewMatchers.withId(R.id.login_username))
.perform(
ViewActions.replaceText(getTestUsername()),
ViewActions.closeSoftKeyboard(),
)
sleep(2000)
.perform(ViewActions.clearText(), ViewActions.typeText(getTestUsername()))
closeSoftKeyboard()
onView(ViewMatchers.withId(R.id.login_password))
.perform(
ViewActions.replaceText(getTestUserPassword()),
ViewActions.closeSoftKeyboard(),
)
sleep(2000)
.perform(ViewActions.clearText(), ViewActions.typeText(getTestUserPassword()))
closeSoftKeyboard()
onView(ViewMatchers.withId(R.id.login_button))
.perform(ViewActions.click())
.perform(ViewActions.click())
sleep(10000)
} catch (ignored: NoMatchingViewException) {
}
}
fun logoutUser() {
try {
onView(
Matchers.allOf(
ViewMatchers.withContentDescription("More"),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
4,
),
ViewMatchers.isDisplayed(),
),
).perform(ViewActions.click())
onView(
Matchers.allOf(
ViewMatchers.withId(R.id.more_logout),
ViewMatchers.withText("Logout"),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet),
0,
),
6,
),
),
).perform(ViewActions.scrollTo(), ViewActions.click())
onView(
Matchers.allOf(
ViewMatchers.withId(android.R.id.button1),
ViewMatchers.withText("Yes"),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.buttonPanel),
0,
),
3,
),
),
).perform(ViewActions.scrollTo(), ViewActions.click())
sleep(5000)
} catch (ignored: NoMatchingViewException) {
}
}
fun childAtPosition(
parentMatcher: Matcher<View>,
position: Int,
): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("Child at position $position in parent ")
parentMatcher.describeTo(description)
}
public override fun matchesSafely(view: View): Boolean {
val parent = view.parent
return parent is ViewGroup &&
parentMatcher.matches(parent) &&
view == parent.getChildAt(position)
}
}
}
fun sleep(timeInMillis: Long) {
@ -163,21 +57,16 @@ class UITestHelper {
val username = BuildConfig.TEST_USERNAME
if (StringUtils.isEmpty(username) || username == "null") {
throw NotImplementedError("Configure your beta account's username")
} else {
return username
}
} else return username
}
private fun getTestUserPassword(): String {
val password = BuildConfig.TEST_PASSWORD
if (StringUtils.isEmpty(password) || password == "null") {
throw NotImplementedError("Configure your beta account's password")
} else {
return password
}
} else return password
}
fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) {
fun <T: Activity> changeOrientation(activityRule: ActivityTestRule<T>){
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
assert(activityRule.activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
@ -187,7 +76,6 @@ class UITestHelper {
fun <T> first(matcher: Matcher<T>): Matcher<T>? {
return object : BaseMatcher<T>() {
var isFirst = true
override fun matches(item: Any): Boolean {
if (isFirst && matcher.matches(item)) {
isFirst = false
@ -202,4 +90,4 @@ class UITestHelper {
}
}
}
}
}

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.upload.UploadActivity
import org.junit.Rule
import org.junit.Test
@ -16,4 +16,4 @@ class UploadActivityTest {
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}
}

View file

@ -1,203 +0,0 @@
package fr.free.nrw.commons
import android.app.Activity
import android.app.Instrumentation
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.locationpicker.LocationPickerActivity
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.auth.LoginActivity
import org.hamcrest.CoreMatchers
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UploadCancelledTest {
@Rule
@JvmField
var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
@Rule
@JvmField
var mGrantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
"android.permission.WRITE_EXTERNAL_STORAGE",
)
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
try {
Intents.init()
} catch (_: IllegalStateException) {
}
device.unfreezeRotation()
device.setOrientationNatural()
device.freezeRotation()
UITestHelper.loginUser()
UITestHelper.skipWelcome()
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@After
fun teardown() {
try {
Intents.release()
} catch (_: IllegalStateException) {
}
}
@Test
fun uploadCancelledAfterLocationPickedTest() {
val bottomNavigationItemView =
onView(
allOf(
childAtPosition(
childAtPosition(
withId(R.id.fragment_main_nav_tab_layout),
0,
),
1,
),
isDisplayed(),
),
)
bottomNavigationItemView.perform(click())
UITestHelper.sleep(12000)
val actionMenuItemView =
onView(
allOf(
withId(R.id.list_sheet),
childAtPosition(
childAtPosition(
withId(R.id.toolbar),
1,
),
0,
),
isDisplayed(),
),
)
actionMenuItemView.perform(click())
val recyclerView =
onView(
allOf(
withId(R.id.rv_nearby_list),
),
)
recyclerView.perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
0,
click(),
),
)
val linearLayout3 =
onView(
allOf(
withId(R.id.cameraButton),
childAtPosition(
allOf(
withId(R.id.nearby_button_layout),
),
0,
),
isDisplayed(),
),
)
linearLayout3.perform(click())
val pasteSensitiveTextInputEditText =
onView(
allOf(
withId(R.id.caption_item_edit_text),
childAtPosition(
childAtPosition(
withId(R.id.caption_item_edit_text_input_layout),
0,
),
0,
),
isDisplayed(),
),
)
pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard())
val pasteSensitiveTextInputEditText2 =
onView(
allOf(
withId(R.id.description_item_edit_text),
childAtPosition(
childAtPosition(
withId(R.id.description_item_edit_text_input_layout),
0,
),
0,
),
isDisplayed(),
),
)
pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard())
val appCompatButton2 =
onView(
allOf(
withId(R.id.btn_next),
childAtPosition(
childAtPosition(
withId(R.id.ll_container_media_detail),
2,
),
1,
),
isDisplayed(),
),
)
appCompatButton2.perform(click())
val appCompatButton3 =
onView(
allOf(
withId(android.R.id.button1),
),
)
appCompatButton3.perform(scrollTo(), click())
Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name))
val floatingActionButton3 =
onView(
allOf(
withId(R.id.location_chosen_button),
isDisplayed(),
),
)
UITestHelper.sleep(2000)
floatingActionButton3.perform(click())
}
}

View file

@ -8,6 +8,7 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions.click
@ -19,22 +20,18 @@ import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import androidx.test.runner.AndroidJUnit4
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.upload.DescriptionsAdapter
import fr.free.nrw.commons.util.MyViewAction
import fr.free.nrw.commons.utils.ConfigUtils
import org.hamcrest.core.AllOf.allOf
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -43,18 +40,14 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Random
import java.util.*
@LargeTest
@RunWith(AndroidJUnit4::class)
class UploadTest {
@get:Rule
var permissionRule =
GrantPermissionRule.grant(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_FINE_LOCATION,
)!!
var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_FINE_LOCATION)!!
@get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java)
@ -71,10 +64,11 @@ class UploadTest {
fun setup() {
try {
Intents.init()
} catch (_: IllegalStateException) {
} catch (ex: IllegalStateException) {
}
UITestHelper.loginUser()
UITestHelper.skipWelcome()
UITestHelper.loginUser()
}
@After
@ -83,9 +77,8 @@ class UploadTest {
}
@Test
@Ignore("Fix Failing Test")
fun testUploadWithDescription() {
if (!ConfigUtils.isBetaFlavour) {
if (!ConfigUtils.isBetaFlavour()) {
throw Error("This test should only be run in Beta!")
}
@ -103,14 +96,15 @@ class UploadTest {
// Try to dismiss the error, if there is one (probably about duplicate files on Commons)
dismissWarning("Yes")
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title)))
.perform(replaceText(commonsFileName))
onView(allOf<View>(isDisplayed(), withId(R.id.et_title)))
.perform(replaceText(commonsFileName))
onView(allOf<View>(isDisplayed(), withId(R.id.description_item_edit_text)))
.perform(replaceText(commonsFileName))
.perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
UITestHelper.sleep(5000)
dismissWarning("Yes")
@ -118,30 +112,29 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
.perform(replaceText("Uploaded with Mobile/Android Tests"))
.perform(replaceText("Uploaded with Mobile/Android Tests"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click())
.perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click())
.perform(click())
UITestHelper.sleep(10000)
val fileUrl =
"https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@ -149,16 +142,15 @@ class UploadTest {
private fun dismissWarning(warningText: String) {
try {
onView(withText(warningText))
.check(matches(isDisplayed()))
.perform(click())
.check(matches(isDisplayed()))
.perform(click())
} catch (ignored: NoMatchingViewException) {
}
}
@Test
@Ignore("Fix Failing Test")
fun testUploadWithoutDescription() {
if (!ConfigUtils.isBetaFlavour) {
if (!ConfigUtils.isBetaFlavour()) {
throw Error("This test should only be run in Beta!")
}
@ -176,11 +168,11 @@ class UploadTest {
// Try to dismiss the error, if there is one (probably about duplicate files on Commons)
dismissWarning("Yes")
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title)))
.perform(replaceText(commonsFileName))
onView(allOf<View>(isDisplayed(), withId(R.id.et_title)))
.perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
UITestHelper.sleep(10000)
dismissWarning("Yes")
@ -188,38 +180,36 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
.perform(replaceText("Test"))
.perform(replaceText("Test"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click())
.perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click())
.perform(click())
UITestHelper.sleep(10000)
val fileUrl =
"https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@Test
@Ignore("Fix Failing Test")
fun testUploadWithMultilingualDescription() {
if (!ConfigUtils.isBetaFlavour) {
if (!ConfigUtils.isBetaFlavour()) {
throw Error("This test should only be run in Beta!")
}
@ -237,30 +227,29 @@ class UploadTest {
// Try to dismiss the error, if there is one (probably about duplicate files on Commons)
dismissWarningDialog()
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title)))
.perform(replaceText(commonsFileName))
onView(allOf<View>(isDisplayed(), withId(R.id.et_title)))
.perform(replaceText(commonsFileName))
onView(withId(R.id.rv_descriptions)).perform(
RecyclerViewActions
.actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(
0,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"),
),
)
RecyclerViewActions
.actionOnItemAtPosition<DescriptionsAdapter.ViewHolder>(0,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description")))
onView(withId(R.id.btn_add))
.perform(click())
onView(withId(R.id.btn_add_description))
.perform(click())
onView(withId(R.id.rv_descriptions)).perform(
RecyclerViewActions
.actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(
1,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"),
),
)
RecyclerViewActions
.actionOnItemAtPosition<DescriptionsAdapter.ViewHolder>(1,
MyViewAction.selectSpinnerItemInChildViewWithId(R.id.spinner_description_languages, 2)))
onView(withId(R.id.rv_descriptions)).perform(
RecyclerViewActions
.actionOnItemAtPosition<DescriptionsAdapter.ViewHolder>(1,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description")))
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
UITestHelper.sleep(5000)
dismissWarning("Yes")
@ -268,30 +257,29 @@ class UploadTest {
UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search)))
.perform(replaceText("Test"))
.perform(replaceText("Test"))
UITestHelper.sleep(3000)
try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click())
.perform(click())
} catch (ignored: NoMatchingViewException) {
}
onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click())
.perform(click())
dismissWarning("Yes, Submit")
UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click())
.perform(click())
UITestHelper.sleep(10000)
val fileUrl =
"https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" +
commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl")
}
@ -324,6 +312,7 @@ class UploadTest {
} catch (e: IOException) {
e.printStackTrace()
}
}
}
@ -345,8 +334,8 @@ class UploadTest {
private fun dismissWarningDialog() {
try {
onView(withText("Yes"))
.check(matches(isDisplayed()))
.perform(click())
.check(matches(isDisplayed()))
.perform(click())
} catch (ignored: NoMatchingViewException) {
}
}
@ -354,10 +343,10 @@ class UploadTest {
private fun openGallery() {
// Open FAB
onView(allOf<View>(withId(R.id.fab_plus), isDisplayed()))
.perform(click())
.perform(click())
// Click gallery
onView(allOf<View>(withId(R.id.fab_gallery), isDisplayed()))
.perform(click())
.perform(click())
}
}
}

View file

@ -5,20 +5,15 @@ import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import androidx.test.runner.AndroidJUnit4
import androidx.viewpager.widget.ViewPager
import fr.free.nrw.commons.utils.ConfigUtils
import org.hamcrest.core.IsNot.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.CoreMatchers.equalTo
@LargeTest
@RunWith(AndroidJUnit4::class)
@ -26,108 +21,86 @@ class WelcomeActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java)
private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setup() {
device.setOrientationNatural()
device.freezeRotation()
}
@Test
fun ifBetaShowsSkipButton() {
if (ConfigUtils.isBetaFlavour) {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
if (ConfigUtils.isBetaFlavour()) {
onView(withId(R.id.finishTutorialButton))
.check(matches(isDisplayed()))
.check(matches(isDisplayed()))
}
}
@Test
fun ifProdHidesSkipButton() {
if (!ConfigUtils.isBetaFlavour) {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
if (!ConfigUtils.isBetaFlavour()) {
onView(withId(R.id.finishTutorialButton))
.check(matches(not(isDisplayed())))
.check(matches(not(isDisplayed())))
}
}
@Test
fun testBetaSkipButton() {
if (ConfigUtils.isBetaFlavour) {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
if (ConfigUtils.isBetaFlavour()) {
onView(withId(R.id.finishTutorialButton))
.perform(ViewActions.click())
assertThat(activityRule.activity.isDestroyed, equalTo(true))
.perform(ViewActions.click())
assert(activityRule.activity.isDestroyed)
}
}
@Test
fun testSwipingOnce() {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeLeft())
assertThat(true, equalTo(true))
.perform(ViewActions.swipeLeft())
assert(true)
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeRight())
assertThat(true, equalTo(true))
.perform(ViewActions.swipeRight())
assert(true)
}
@Test
fun testSwipingWholeTutorial() {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
assertThat(true, equalTo(true))
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
.perform(ViewActions.swipeLeft())
assert(true)
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
assertThat(true, equalTo(true))
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
.perform(ViewActions.swipeRight())
assert(true)
}
@Test
fun swipeBeyondBounds() {
val viewPager = activityRule.activity.findViewById<ViewPager>(R.id.welcomePager)
fun swipeBeyondBounds(){
var view_pager=activityRule.activity.findViewById<ViewPager>(R.id.welcomePager)
viewPager.adapter?.let {
if (viewPager.currentItem == 3) {
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeLeft())
assertThat(true, equalTo(true))
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeRight())
assertThat(true, equalTo(true))
}
}
view_pager.adapter?.let { view_pager.currentItem == view_pager.adapter?.count?.minus(1)
if (view_pager.currentItem==3){
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeLeft())
assert(true)
onView(withId(R.id.welcomePager))
.perform(ViewActions.swipeRight())
assert(false)
}}
}
@Test
fun swipeTillLastAndFinish() {
val viewPager = activityRule.activity.findViewById<ViewPager>(R.id.welcomePager)
fun swipeTillLastAndFinish(){
var view_pager=activityRule.activity.findViewById<ViewPager>(R.id.welcomePager)
viewPager.adapter?.let {
if (viewPager.currentItem == 3) {
onView(withId(R.id.button_ok))
.perform(ViewActions.click())
onView(withId(R.id.finishTutorialButton))
.perform(ViewActions.click())
assertThat(activityRule.activity.isDestroyed, equalTo(true))
}
}
view_pager.adapter?.let { view_pager.currentItem == view_pager.adapter?.count?.minus(1)
if (view_pager.currentItem==3){
onView(withId(R.id.finishTutorialButton))
.perform(ViewActions.click())
assert(activityRule.activity.isDestroyed)
}}
}
@Test
fun orientationChange() {
UITestHelper.changeOrientation(activityRule)
}
}
}

View file

@ -1,271 +0,0 @@
package fr.free.nrw.commons.contributions
import android.content.res.Configuration
import android.os.Looper
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.lifecycle.Lifecycle
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.material.floatingactionbutton.FloatingActionButton
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.createTestClient
import fr.free.nrw.commons.upload.WikidataPlace
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import java.lang.reflect.Method
@RunWith(AndroidJUnit4::class)
@Config(sdk = [21], application = TestCommonsApplication::class)
@LooperMode(LooperMode.Mode.PAUSED)
class ContributionsListFragmentUnitTests {
private lateinit var scenario: FragmentScenario<ContributionsListFragment>
private lateinit var fragment: ContributionsListFragment
private val adapter: ContributionsListAdapter = mock()
private val contribution: Contribution = mock()
private val media: Media = mock()
private val wikidataPlace: WikidataPlace = mock()
@Before
fun setUp() {
OkHttpConnectionFactory.CLIENT = createTestClient()
scenario =
launchFragmentInContainer(
initialState = Lifecycle.State.RESUMED,
themeResId = R.style.LightAppTheme,
) {
ContributionsListFragment()
.apply {
contributionsListPresenter = mock()
callback = mock()
}.also {
fragment = it
}
}
scenario.onFragment {
it.adapter = adapter
}
}
@Test
@Throws(Exception::class)
fun checkFragmentNotNull() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Assert.assertNotNull(fragment)
}
@Test
@Throws(Exception::class)
fun testOnDetach() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.onDetach()
}
@Test
@Throws(Exception::class)
fun testGetContributionStateAt() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution)
fragment.getContributionStateAt(0)
}
@Test
@Throws(Exception::class)
fun testOnScrollToTop() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.rvContributionsList = mock()
fragment.scrollToTop()
verify(fragment.rvContributionsList)?.smoothScrollToPosition(0)
}
@Test
@Throws(Exception::class)
fun testOnConfirmClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(contribution.media).thenReturn(media)
`when`(media.wikiCode).thenReturn("")
`when`(contribution.wikidataPlace).thenReturn(wikidataPlace)
fragment.onConfirmClicked(contribution, true)
}
@Test
@Throws(Exception::class)
fun testGetTotalMediaCount() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.totalMediaCount
}
@Test
@Throws(Exception::class)
fun testGetMediaAtPositionCaseNonNull() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(adapter.getContributionForPosition(anyInt())).thenReturn(contribution)
`when`(contribution.media).thenReturn(media)
fragment.getMediaAtPosition(0)
}
@Test
@Throws(Exception::class)
fun testGetMediaAtPositionCaseNull() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(adapter.getContributionForPosition(anyInt())).thenReturn(null)
fragment.getMediaAtPosition(0)
}
@Test
@Throws(Exception::class)
fun testShowAddImageToWikipediaInstructions() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"showAddImageToWikipediaInstructions",
Contribution::class.java,
)
method.isAccessible = true
method.invoke(fragment, contribution)
}
@Test
@Throws(Exception::class)
fun testAddImageToWikipedia() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.addImageToWikipedia(contribution)
}
@Test
@Throws(Exception::class)
fun testOpenMediaDetail() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.openMediaDetail(0, true)
}
@Test
@Throws(Exception::class)
fun testOnViewStateRestored() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.onViewStateRestored(mock())
}
@Test
@Throws(Exception::class)
fun testOnSaveInstanceState() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.onSaveInstanceState(mock())
}
@Test
@Throws(Exception::class)
fun testShowNoContributionsUI() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.showNoContributionsUI(true)
}
@Test
@Throws(Exception::class)
fun testShowProgress() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.showProgress(true)
}
@Test
@Throws(Exception::class)
fun testShowWelcomeTip() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.showWelcomeTip(true)
}
@Test
@Throws(Exception::class)
fun testAnimateFAB() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment {
it.requireView().findViewById<FloatingActionButton>(R.id.fab_plus).hide()
}
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"animateFAB",
Boolean::class.java,
)
method.isAccessible = true
method.invoke(fragment, true)
}
@Test
@Throws(Exception::class)
fun testAnimateFABCaseShownAndOpen() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment {
it.requireView().findViewById<FloatingActionButton>(R.id.fab_plus).show()
}
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"animateFAB",
Boolean::class.java,
)
method.isAccessible = true
method.invoke(fragment, true)
}
@Test
@Throws(Exception::class)
fun testAnimateFABCaseShownAndClose() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment {
it.requireView().findViewById<FloatingActionButton>(R.id.fab_plus).show()
}
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"animateFAB",
Boolean::class.java,
)
method.isAccessible = true
method.invoke(fragment, false)
}
@Test
@Throws(Exception::class)
fun testSetListeners() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"setListeners",
)
method.isAccessible = true
method.invoke(fragment)
}
@Test
@Throws(Exception::class)
fun testInitializeAnimations() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method =
ContributionsListFragment::class.java.getDeclaredMethod(
"initializeAnimations",
)
method.isAccessible = true
method.invoke(fragment)
}
@Test
@Throws(Exception::class)
fun testOnConfigurationChanged() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val newConfig: Configuration = mock()
newConfig.orientation = Configuration.ORIENTATION_LANDSCAPE
fragment.onConfigurationChanged(newConfig)
}
}

View file

@ -1,61 +0,0 @@
package fr.free.nrw.commons.navtab
import android.os.Looper
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.lifecycle.Lifecycle
import androidx.test.ext.junit.runners.AndroidJUnit4
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
@RunWith(AndroidJUnit4::class)
@Config(sdk = [21], application = TestCommonsApplication::class)
@LooperMode(LooperMode.Mode.PAUSED)
class MoreBottomSheetLoggedOutFragmentUnitTests {
private lateinit var scenario: FragmentScenario<MoreBottomSheetLoggedOutFragment>
@Before
fun setUp() {
scenario =
launchFragmentInContainer(
initialState = Lifecycle.State.RESUMED,
themeResId = R.style.LightAppTheme,
) {
MoreBottomSheetLoggedOutFragment()
}
}
@Test
@Throws(Exception::class)
fun testOnSettingsClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment { it.onSettingsClicked() }
}
@Test
@Throws(Exception::class)
fun testOnAboutClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment { it.onAboutClicked() }
}
@Test
@Throws(Exception::class)
fun testOnFeedbackClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment { it.onFeedbackClicked() }
}
@Test
@Throws(Exception::class)
fun testOnLogoutClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
scenario.onFragment { it.onLogoutClicked() }
}
}

View file

@ -1,46 +0,0 @@
package fr.free.nrw.commons.ui
import android.content.Context
import android.util.AttributeSet
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PasteSensitiveTextInputEditTextTest {
private var context: Context? = null
private var textView: PasteSensitiveTextInputEditText? = null
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
textView = PasteSensitiveTextInputEditText(context!!)
}
// this test has no real value, just % for test code coverage
@Test
fun extractFormattingAttributeSet() {
val methodExtractFormattingAttribute =
textView!!.javaClass.getDeclaredMethod(
"extractFormattingAttribute",
Context::class.java,
AttributeSet::class.java,
)
methodExtractFormattingAttribute.isAccessible = true
methodExtractFormattingAttribute.invoke(textView, context, null)
}
@Test
@Throws(Exception::class)
fun setFormattingAllowed() {
val fieldFormattingAllowed = textView!!.javaClass.getDeclaredField("formattingAllowed")
fieldFormattingAllowed.isAccessible = true
textView!!.setFormattingAllowed(true)
Assert.assertTrue(fieldFormattingAllowed.getBoolean(textView))
textView!!.setFormattingAllowed(false)
Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView))
}
}

View file

@ -9,58 +9,56 @@ import org.hamcrest.Matcher
class MyViewAction {
companion object {
fun typeTextInChildViewWithId(
id: Int,
textToBeTyped: String,
): ViewAction =
object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return null
}
override fun getDescription(): String = "Click on a child view with specified id."
override fun getDescription(): String {
return "Click on a child view with specified id."
}
override fun perform(
uiController: UiController,
view: View,
) {
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id) as EditText
v.setText(textToBeTyped)
}
}
}
fun selectSpinnerItemInChildViewWithId(
id: Int,
position: Int,
): ViewAction =
object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return null
}
override fun getDescription(): String = "Click on a child view with specified id."
override fun getDescription(): String {
return "Click on a child view with specified id."
}
override fun perform(
uiController: UiController,
view: View,
) {
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id) as AppCompatSpinner
v.setSelection(position)
}
}
}
fun clickItemWithId(
id: Int,
position: Int,
): ViewAction =
object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
fun clickItemWithId(id: Int, position: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return null
}
override fun getDescription(): String = "Click on a child view with specified id."
override fun getDescription(): String {
return "Click on a child view with specified id."
}
override fun perform(
uiController: UiController,
view: View,
) {
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id) as View
v.performClick()
}
}
}
}
}
}

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<shortcut
android:enabled="true"
android:icon="@mipmap/ic_settings_black"
android:shortcutId="Setting"
android:shortcutLongLabel="@string/title_app_shortcut_setting"
android:shortcutShortLabel="@string/title_app_shortcut_setting"
tools:targetApi="n_mr1">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="fr.free.nrw.commons.settings.SettingsActivity"
android:targetPackage="fr.free.nrw.commons.beta" />
</shortcut>
</shortcuts>

View file

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

View file

@ -0,0 +1,160 @@
package fr.free.nrw.commons;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableString;
import android.text.style.UnderlineSpan;
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 android.widget.TextView;
import org.wikipedia.util.StringUtil;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
import fr.free.nrw.commons.ui.widget.HtmlTextView;
import fr.free.nrw.commons.utils.ConfigUtils;
/**
* Represents about screen of this app
*/
public class AboutActivity extends NavigationBaseActivity {
@BindView(R.id.about_version) TextView versionText;
@BindView(R.id.about_license) HtmlTextView aboutLicenseText;
@BindView(R.id.about_faq) TextView faqText;
@BindView(R.id.about_improve) HtmlTextView improve;
@BindView(R.id.about_rate_us) TextView rateUsText;
@BindView(R.id.about_privacy_policy) TextView privacyPolicyText;
@BindView(R.id.about_translate) TextView translateText;
@BindView(R.id.about_credits) TextView creditsText;
/**
* This method helps in the creation About screen
*
* @param savedInstanceState Data bundle
*/
@Override
@SuppressLint("StringFormatInvalid")
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_about);
ButterKnife.bind(this);
String aboutText = getString(R.string.about_license);
aboutLicenseText.setHtmlText(aboutText);
@SuppressLint("StringFormatMatches")
String improveText = String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL);
improve.setHtmlText(improveText);
versionText.setText(ConfigUtils.getVersionNameWithSha(getApplicationContext()));
Utils.setUnderlinedText(faqText, R.string.about_faq, getApplicationContext());
Utils.setUnderlinedText(rateUsText, R.string.about_rate_us, getApplicationContext());
Utils.setUnderlinedText(privacyPolicyText, R.string.about_privacy_policy, getApplicationContext());
Utils.setUnderlinedText(translateText, R.string.about_translate, getApplicationContext());
Utils.setUnderlinedText(creditsText, R.string.about_credits, getApplicationContext());
initDrawer();
}
@OnClick(R.id.facebook_launch_icon)
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));
}
}
@OnClick(R.id.github_launch_icon)
public void launchGithub(View view) {
Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
}
@OnClick(R.id.website_launch_icon)
public void launchWebsite(View view) {
Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL));
}
@OnClick(R.id.about_rate_us)
public void launchRatings(View view){
Utils.rateApp(this);
}
@OnClick(R.id.about_credits)
public void launchCredits(View view) {
Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL));
}
@OnClick(R.id.about_privacy_policy)
public void launchPrivacyPolicy(View view) {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
}
@OnClick(R.id.about_faq)
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);
}
}
@OnClick(R.id.about_translate)
public void launchTranslate(View view) {
final ArrayAdapter<String> languageAdapter = new ArrayAdapter<>(AboutActivity.this,
android.R.layout.simple_spinner_dropdown_item, CommonsApplication.getInstance().getLanguageLookUpTable().getLocalizedNames());
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);
AlertDialog.Builder builder = new AlertDialog.Builder(AboutActivity.this);
builder.setView(spinner);
builder.setTitle(R.string.about_translate_title)
.setMessage(R.string.about_translate_message)
.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> {
String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
});
builder.setNegativeButton(R.string.about_translate_cancel, (dialog, which) -> dialog.cancel());
builder.create().show();
}
}

View file

@ -1,207 +0,0 @@
package fr.free.nrw.commons
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import fr.free.nrw.commons.CommonsApplication.Companion.instance
import fr.free.nrw.commons.databinding.ActivityAboutBinding
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import java.util.Collections
import androidx.core.net.toUri
import fr.free.nrw.commons.utils.applyEdgeToEdgeTopInsets
import fr.free.nrw.commons.utils.handleWebUrl
import fr.free.nrw.commons.utils.setUnderlinedText
/**
* Represents about screen of this app
*/
class AboutActivity : BaseActivity() {
/*
This View Binding class is auto-generated for each xml file. The format is usually the name
of the file with PascalCasing (The underscore characters will be ignored).
More information is available at https://developer.android.com/topic/libraries/view-binding
*/
private var binding: ActivityAboutBinding? = null
/**
* This method helps in the creation About screen
*
* @param savedInstanceState Data bundle
*/
@SuppressLint("StringFormatInvalid") //TODO:
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/*
Instead of just setting the view with the xml file. We need to use View Binding class.
*/
binding = ActivityAboutBinding.inflate(layoutInflater)
val view: View = binding!!.root
applyEdgeToEdgeTopInsets(binding!!.toolbarLayout)
setContentView(view)
setSupportActionBar(binding!!.toolbarBinding.toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
val aboutText = getString(R.string.about_license)
/*
We can then access all the views by just using the id names like this.
camelCasing is used with underscore characters being ignored.
*/
binding!!.aboutLicense.setHtmlText(aboutText)
@SuppressLint("StringFormatMatches") // TODO:
val improveText =
String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL)
binding!!.aboutImprove.setHtmlText(improveText)
binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha()
binding!!.aboutFaq.setUnderlinedText(R.string.about_faq)
binding!!.aboutRateUs.setUnderlinedText(R.string.about_rate_us)
binding!!.aboutUserGuide.setUnderlinedText(R.string.user_guide)
binding!!.aboutPrivacyPolicy.setUnderlinedText(R.string.about_privacy_policy)
binding!!.aboutTranslate.setUnderlinedText(R.string.about_translate)
binding!!.aboutCredits.setUnderlinedText(R.string.about_credits)
/*
To set listeners, we can create a separate method and use lambda syntax.
*/
binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook)
binding!!.githubLaunchIcon.setOnClickListener(::launchGithub)
binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite)
binding!!.aboutRateUs.setOnClickListener(::launchRatings)
binding!!.aboutCredits.setOnClickListener(::launchCredits)
binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy)
binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide)
binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions)
binding!!.aboutTranslate.setOnClickListener(::launchTranslate)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
fun launchFacebook(view: View?) {
val intent: Intent
try {
intent = Intent(ACTION_VIEW, Urls.FACEBOOK_APP_URL.toUri())
intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME)
startActivity(intent)
} catch (e: Exception) {
handleWebUrl(this, Urls.FACEBOOK_WEB_URL.toUri())
}
}
fun launchGithub(view: View?) {
val intent: Intent
try {
intent = Intent(ACTION_VIEW, Urls.GITHUB_REPO_URL.toUri())
intent.setPackage(Urls.GITHUB_PACKAGE_NAME)
startActivity(intent)
} catch (e: Exception) {
handleWebUrl(this, Urls.GITHUB_REPO_URL.toUri())
}
}
fun launchWebsite(view: View?) {
handleWebUrl(this, Urls.WEBSITE_URL.toUri())
}
fun launchRatings(view: View?) {
try {
startActivity(
Intent(
ACTION_VIEW,
(Urls.PLAY_STORE_PREFIX + packageName).toUri()
)
)
} catch (_: ActivityNotFoundException) {
handleWebUrl(this, (Urls.PLAY_STORE_URL_PREFIX + packageName).toUri())
}
}
fun launchCredits(view: View?) {
handleWebUrl(this, Urls.CREDITS_URL.toUri())
}
fun launchUserGuide(view: View?) {
handleWebUrl(this, Urls.USER_GUIDE_URL.toUri())
}
fun launchPrivacyPolicy(view: View?) {
handleWebUrl(this, BuildConfig.PRIVACY_POLICY_URL.toUri())
}
fun launchFrequentlyAskedQuesions(view: View?) {
handleWebUrl(this, Urls.FAQ_URL.toUri())
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater = menuInflater
inflater.inflate(R.menu.menu_about, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.share_app_icon -> {
val shareText = String.format(
getString(R.string.share_text),
Urls.PLAY_STORE_URL_PREFIX + this.packageName
)
val sendIntent = Intent()
sendIntent.setAction(Intent.ACTION_SEND)
sendIntent.putExtra(Intent.EXTRA_TEXT, shareText)
sendIntent.setType("text/plain")
startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via)))
return true
}
else -> return super.onOptionsItemSelected(item)
}
}
fun launchTranslate(view: View?) {
val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames()
Collections.sort(sortedLocalizedNamesRef)
val languageAdapter = ArrayAdapter(
this@AboutActivity,
android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef
)
val spinner = Spinner(this@AboutActivity)
spinner.layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
spinner.adapter = languageAdapter
spinner.gravity = 17
spinner.setPadding(50, 0, 0, 0)
val positiveButtonRunnable = Runnable {
val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition]
handleWebUrl(this@AboutActivity, (Urls.TRANSLATE_WIKI_URL + langCode).toUri())
}
showAlertDialog(
this,
getString(R.string.about_translate_title),
getString(R.string.about_translate_message),
getString(R.string.about_translate_proceed),
getString(R.string.about_translate_cancel),
positiveButtonRunnable,
{},
spinner
)
}
}

View file

@ -1,63 +0,0 @@
package fr.free.nrw.commons
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
class BaseMarker {
private var _position: LatLng = LatLng(0.0, 0.0, 0f)
private var _title: String = ""
private var _place: Place = Place()
private var _icon: Bitmap? = null
var position: LatLng
get() = _position
set(value) {
_position = value
}
var title: String
get() = _title
set(value) {
_title = value
}
var place: Place
get() = _place
set(value) {
_place = value
}
var icon: Bitmap?
get() = _icon
set(value) {
_icon = value
}
constructor() {
}
fun fromResource(
context: Context,
drawableResId: Int,
) {
val drawable: Drawable = context.resources.getDrawable(drawableResId)
icon =
if (drawable is BitmapDrawable) {
drawable.bitmap
} else {
val bitmap =
Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888,
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bitmap
}
}
}

View file

@ -0,0 +1,16 @@
package fr.free.nrw.commons;
/**
* Base presenter, enforcing contracts to atach and detach view
*/
public interface BasePresenter<T> {
/**
* Until a view is attached, it is open to listen events from the presenter
*/
void onAttachView(T view);
/**
* Detaching a view makes sure that the view no more receives events from the presenter
*/
void onDetachView();
}

View file

@ -1,10 +0,0 @@
package fr.free.nrw.commons
/**
* Base presenter, enforcing contracts to attach and detach view
*/
interface BasePresenter<T> {
fun onAttachView(view: T)
fun onDetachView()
}

View file

@ -1,19 +0,0 @@
package fr.free.nrw.commons
/**
* Production variant related constants which is used in beta variant for some specific GET calls on
* production server where beta server does not work
*/
object BetaConstants {
/**
* Commons production URL which is used in beta for some specific GET calls on
* production server where beta server does not work
*/
const val COMMONS_URL = "https://commons.wikimedia.org/"
/**
* Commons production's depicts property which is used in beta for some specific GET calls on
* production server where beta server does not work
*/
const val DEPICTS_PROPERTY = "P180"
}

View file

@ -1,33 +0,0 @@
package fr.free.nrw.commons
import android.os.Parcel
import android.os.Parcelable
class CameraPosition(
val latitude: Double,
val longitude: Double,
val zoom: Double,
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readDouble(),
parcel.readDouble(),
parcel.readDouble(),
)
override fun writeToParcel(
parcel: Parcel,
flags: Int,
) {
parcel.writeDouble(latitude)
parcel.writeDouble(longitude)
parcel.writeDouble(zoom)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<CameraPosition> {
override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel)
override fun newArray(size: Int): Array<CameraPosition?> = arrayOfNulls(size)
}
}

View file

@ -0,0 +1,86 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
import org.wikipedia.AppAdapter;
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.json.GsonMarshaller;
import org.wikipedia.json.GsonUnmarshaller;
import org.wikipedia.login.LoginResult;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import okhttp3.OkHttpClient;
public class CommonsAppAdapter extends AppAdapter {
private final int DEFAULT_THUMB_SIZE = 640;
private final String COOKIE_STORE_NAME = "cookie_store";
private final SessionManager sessionManager;
private final JsonKvStore preferences;
CommonsAppAdapter(@NonNull SessionManager sessionManager, @NonNull JsonKvStore preferences) {
this.sessionManager = sessionManager;
this.preferences = preferences;
}
@Override
public String getMediaWikiBaseUrl() {
return BuildConfig.COMMONS_URL;
}
@Override
public String getRestbaseUriFormat() {
return BuildConfig.COMMONS_URL;
}
@Override
public OkHttpClient getOkHttpClient(@NonNull WikiSite wikiSite) {
return OkHttpConnectionFactory.getClient();
}
@Override
public int getDesiredLeadImageDp() {
return DEFAULT_THUMB_SIZE;
}
@Override
public boolean isLoggedIn() {
return sessionManager.isUserLoggedIn();
}
@Override
public String getUserName() {
return sessionManager.getUserName();
}
@Override
public String getPassword() {
return sessionManager.getPassword();
}
@Override
public void updateAccount(@NonNull LoginResult result) {
sessionManager.updateAccount(result);
}
@Override
public SharedPreferenceCookieManager getCookies() {
if (!preferences.contains(COOKIE_STORE_NAME)) {
return null;
}
return GsonUnmarshaller.unmarshal(SharedPreferenceCookieManager.class,
preferences.getString(COOKIE_STORE_NAME, null));
}
@Override
public void setCookies(@NonNull SharedPreferenceCookieManager cookies) {
preferences.putString(COOKIE_STORE_NAME, GsonMarshaller.marshal(cookies));
}
@Override
public boolean logErrorsInsteadOfCrashing() {
return false;
}
}

View file

@ -0,0 +1,312 @@
package fr.free.nrw.commons;
import android.annotation.SuppressLint;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.os.Process;
import android.util.Log;
import androidx.annotation.NonNull;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imagepipeline.core.ImagePipeline;
import com.facebook.imagepipeline.core.ImagePipelineConfig;
import com.mapbox.mapboxsdk.Mapbox;
import com.squareup.leakcanary.LeakCanary;
import com.squareup.leakcanary.RefWatcher;
import org.acra.ACRA;
import org.acra.annotation.AcraCore;
import org.acra.annotation.AcraDialog;
import org.acra.annotation.AcraMailSender;
import org.acra.data.StringFormat;
import org.wikipedia.AppAdapter;
import org.wikipedia.language.AppLanguageLookUpTable;
import java.io.File;
import java.util.HashSet;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler;
import fr.free.nrw.commons.concurrency.ThreadPoolService;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.logging.FileLoggingTree;
import fr.free.nrw.commons.logging.LogUtils;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.FileUtils;
import fr.free.nrw.commons.utils.ConfigUtils;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.internal.functions.Functions;
import io.reactivex.plugins.RxJavaPlugins;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
import static fr.free.nrw.commons.data.DBOpenHelper.CONTRIBUTIONS_TABLE;
import static org.acra.ReportField.ANDROID_VERSION;
import static org.acra.ReportField.APP_VERSION_CODE;
import static org.acra.ReportField.APP_VERSION_NAME;
import static org.acra.ReportField.PHONE_MODEL;
import static org.acra.ReportField.STACK_TRACE;
import static org.acra.ReportField.USER_COMMENT;
@AcraCore(
buildConfigClass = BuildConfig.class,
resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
reportFormat = StringFormat.KEY_VALUE_LIST,
reportContent = {USER_COMMENT, APP_VERSION_CODE, APP_VERSION_NAME, ANDROID_VERSION, PHONE_MODEL, STACK_TRACE}
)
@AcraMailSender(
mailTo = "commons-app-android-private@googlegroups.com"
)
@AcraDialog(
resTheme = R.style.Theme_AppCompat_Dialog,
resText = R.string.crash_dialog_text,
resTitle = R.string.crash_dialog_title,
resCommentPrompt = R.string.crash_dialog_comment_prompt
)
public class CommonsApplication extends Application {
@Inject SessionManager sessionManager;
@Inject DBOpenHelper dbOpenHelper;
@Inject @Named("default_preferences") JsonKvStore defaultPrefs;
/**
* Constants begin
*/
public static final int OPEN_APPLICATION_DETAIL_SETTINGS = 1001;
public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using [[COM:MOA|Commons Mobile App]]";
public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App Feedback";
public static final String NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll";
public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --";
/**
* Constants End
*/
private RefWatcher refWatcher;
private static CommonsApplication INSTANCE;
public static CommonsApplication getInstance() {
return INSTANCE;
}
private AppLanguageLookUpTable languageLookUpTable;
public AppLanguageLookUpTable getLanguageLookUpTable() {
return languageLookUpTable;
}
@Inject
AppDatabase appDatabase;
/**
* Used to declare and initialize various components and dependencies
*/
@Override
public void onCreate() {
super.onCreate();
INSTANCE = this;
ACRA.init(this);
Mapbox.getInstance(this, getString(R.string.mapbox_commons_app_token));
ApplicationlessInjection
.getInstance(this)
.getCommonsApplicationComponent()
.inject(this);
AppAdapter.set(new CommonsAppAdapter(sessionManager, defaultPrefs));
initTimber();
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
Set<String> defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS);
if (null == defaultExifTagsSet) {
defaultExifTagsSet = new HashSet<>();
}
defaultExifTagsSet.add(getString(R.string.exif_tag_location));
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet);
}
// Set DownsampleEnabled to True to downsample the image in case it's heavy
ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
.setDownsampleEnabled(true)
.build();
try {
Fresco.initialize(this, config);
} catch (Exception e) {
Timber.e(e);
// TODO: Remove when we're able to initialize Fresco in test builds.
}
createNotificationChannel(this);
languageLookUpTable = new AppLanguageLookUpTable(this);
// This handler will catch exceptions thrown from Observables after they are disposed,
// or from Observables that are (deliberately or not) missing an onError handler.
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer());
if (setupLeakCanary() == RefWatcher.DISABLED) {
return;
}
// Fire progress callbacks for every 3% of uploaded content
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
}
/**
* Plants debug and file logging tree.
* Timber lets you plant your own logging trees.
*
*/
private void initTimber() {
boolean isBeta = ConfigUtils.isBetaFlavour();
String logFileName = isBeta ? "CommonsBetaAppLogs" : "CommonsAppLogs";
String logDirectory = LogUtils.getLogDirectory();
FileLoggingTree tree = new FileLoggingTree(
Log.VERBOSE,
logFileName,
logDirectory,
1000,
getFileLoggingThreadPool());
Timber.plant(tree);
Timber.plant(new Timber.DebugTree());
}
public static boolean isRoboUnitTest() {
return "robolectric".equals(Build.FINGERPRINT);
}
private ThreadPoolService getFileLoggingThreadPool() {
return new ThreadPoolService.Builder("file-logging-thread")
.setPriority(Process.THREAD_PRIORITY_LOWEST)
.setPoolSize(1)
.setExceptionHandler(new BackgroundPoolExceptionHandler())
.build();
}
public static void createNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL);
if (channel == null) {
channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID_ALL,
context.getString(R.string.notifications_channel_name_all), NotificationManager.IMPORTANCE_DEFAULT);
manager.createNotificationChannel(channel);
}
}
}
public String getUserAgent() {
return "Commons/" + ConfigUtils.getVersionNameWithSha(this) + " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE;
}
/**
* Helps in setting up LeakCanary library
* @return instance of LeakCanary
*/
protected RefWatcher setupLeakCanary() {
if (LeakCanary.isInAnalyzerProcess(this)) {
return RefWatcher.DISABLED;
}
return LeakCanary.install(this);
}
/**
* Provides a way to get member refWatcher
*
* @param context Application context
* @return application member refWatcher
*/
public static RefWatcher getRefWatcher(Context context) {
CommonsApplication application = (CommonsApplication) context.getApplicationContext();
return application.refWatcher;
}
/**
* clears data of current application
* @param context Application context
* @param logoutListener Implementation of interface LogoutListener
*/
@SuppressLint("CheckResult")
public void clearApplicationData(Context context, LogoutListener logoutListener) {
File cacheDirectory = context.getCacheDir();
File applicationDirectory = new File(cacheDirectory.getParent());
if (applicationDirectory.exists()) {
String[] fileNames = applicationDirectory.list();
for (String fileName : fileNames) {
if (!fileName.equals("lib")) {
FileUtils.deleteFile(new File(applicationDirectory, fileName));
}
}
}
sessionManager.logout()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
Timber.d("All accounts have been removed");
clearImageCache();
//TODO: fix preference manager
defaultPrefs.clearAll();
defaultPrefs.putBoolean("firstrun", false);
updateAllDatabases();
logoutListener.onLogoutComplete();
});
}
/**
* Clear all images cache held by Fresco
*/
private void clearImageCache() {
ImagePipeline imagePipeline = Fresco.getImagePipeline();
imagePipeline.clearCaches();
}
/**
* Deletes all tables and re-creates them.
*/
private void updateAllDatabases() {
dbOpenHelper.getReadableDatabase().close();
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
CategoryDao.Table.onDelete(db);
dbOpenHelper.deleteTable(db,CONTRIBUTIONS_TABLE);//Delete the contributions table in the existing db on older versions
appDatabase.getContributionDao().deleteAll();
BookmarkPicturesDao.Table.onDelete(db);
BookmarkLocationsDao.Table.onDelete(db);
}
/**
* Interface used to get log-out events
*/
public interface LogoutListener {
void onLogoutComplete();
}
}

View file

@ -1,417 +0,0 @@
package fr.free.nrw.commons
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Process
import android.util.Log
import androidx.multidex.MultiDexApplication
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.core.ImagePipelineConfig
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable
import fr.free.nrw.commons.bookmarks.pictures.BookmarksTable
import fr.free.nrw.commons.category.CategoryDao
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
import fr.free.nrw.commons.concurrency.ThreadPoolService
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.language.AppLanguageLookUpTable
import fr.free.nrw.commons.logging.FileLoggingTree
import fr.free.nrw.commons.logging.LogUtils
import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.FileUtils
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
import io.reactivex.Completable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.internal.functions.Functions
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import org.acra.ACRA.init
import org.acra.ReportField
import org.acra.annotation.AcraCore
import org.acra.annotation.AcraDialog
import org.acra.annotation.AcraMailSender
import org.acra.data.StringFormat
import timber.log.Timber
import timber.log.Timber.DebugTree
import java.io.File
import javax.inject.Inject
import javax.inject.Named
@AcraCore(
buildConfigClass = BuildConfig::class,
resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
reportFormat = StringFormat.KEY_VALUE_LIST,
reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE]
)
@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false)
@AcraDialog(
resTheme = R.style.Theme_AppCompat_Dialog,
resText = R.string.crash_dialog_text,
resTitle = R.string.crash_dialog_title,
resCommentPrompt = R.string.crash_dialog_comment_prompt
)
class CommonsApplication : MultiDexApplication() {
@Inject
lateinit var sessionManager: SessionManager
@Inject
lateinit var dbOpenHelper: DBOpenHelper
@Inject
@field:Named("default_preferences")
lateinit var defaultPrefs: JsonKvStore
@Inject
lateinit var cookieJar: CommonsCookieJar
@Inject
lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher
var languageLookUpTable: AppLanguageLookUpTable? = null
private set
@Inject
lateinit var contributionDao: ContributionDao
/**
* Used to declare and initialize various components and dependencies
*/
override fun onCreate() {
super.onCreate()
instance = this
init(this)
ApplicationlessInjection
.getInstance(this)
.commonsApplicationComponent
.inject(this)
initTimber()
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS)
if (null == defaultExifTagsSet) {
defaultExifTagsSet = HashSet()
}
defaultExifTagsSet.add(getString(R.string.exif_tag_location))
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet)
}
// Set DownsampleEnabled to True to downsample the image in case it's heavy
val config = ImagePipelineConfig.newBuilder(this)
.setNetworkFetcher(customOkHttpNetworkFetcher)
.setDownsampleEnabled(true)
.build()
try {
Fresco.initialize(this, config)
} catch (e: Exception) {
Timber.e(e)
// TODO: Remove when we're able to initialize Fresco in test builds.
}
createNotificationChannel(this)
languageLookUpTable = AppLanguageLookUpTable(this)
// This handler will catch exceptions thrown from Observables after they are disposed,
// or from Observables that are (deliberately or not) missing an onError handler.
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer())
// Fire progress callbacks for every 3% of uploaded content
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
}
/**
* Plants debug and file logging tree. Timber lets you plant your own logging trees.
*/
private fun initTimber() {
val isBeta = isBetaFlavour
val logFileName =
if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs"
val logDirectory = LogUtils.getLogDirectory()
//Delete stale logs if they have exceeded the specified size
deleteStaleLogs(logFileName, logDirectory)
val tree = FileLoggingTree(
Log.VERBOSE,
logFileName,
logDirectory,
1000,
fileLoggingThreadPool
)
Timber.plant(tree)
Timber.plant(DebugTree())
}
/**
* Deletes the logs zip file at the specified directory and file locations specified in the
* params
*
* @param logFileName
* @param logDirectory
*/
private fun deleteStaleLogs(logFileName: String, logDirectory: String) {
try {
val file = File("$logDirectory/zip/$logFileName.zip")
if (file.exists() && file.totalSpace > 1000000) { // In Kbs
file.delete()
}
} catch (e: Exception) {
Timber.e(e)
}
}
private val fileLoggingThreadPool: ThreadPoolService
get() = ThreadPoolService.Builder("file-logging-thread")
.setPriority(Process.THREAD_PRIORITY_LOWEST)
.setPoolSize(1)
.setExceptionHandler(BackgroundPoolExceptionHandler())
.build()
val userAgent: String
get() = ("Commons/" + this.getVersionNameWithSha()
+ " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE)
/**
* clears data of current application
*
* @param context Application context
* @param logoutListener Implementation of interface LogoutListener
*/
@SuppressLint("CheckResult")
fun clearApplicationData(context: Context, logoutListener: LogoutListener) {
val cacheDirectory = context.cacheDir
val applicationDirectory = File(cacheDirectory.parent)
if (applicationDirectory.exists()) {
val fileNames = applicationDirectory.list()
for (fileName in fileNames) {
if (fileName != "lib") {
FileUtils.deleteFile(File(applicationDirectory, fileName))
}
}
}
sessionManager.logout()
.andThen(Completable.fromAction { cookieJar.clear() })
.andThen(Completable.fromAction {
Timber.d("All accounts have been removed")
clearImageCache()
//TODO: fix preference manager
defaultPrefs.clearAll()
defaultPrefs.putBoolean("firstrun", false)
updateAllDatabases()
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) })
}
/**
* Clear all images cache held by Fresco
*/
private fun clearImageCache() {
val imagePipeline = Fresco.getImagePipeline()
imagePipeline.clearCaches()
}
/**
* Deletes all tables and re-creates them.
*/
private fun updateAllDatabases() {
dbOpenHelper.readableDatabase.close()
val db = dbOpenHelper.writableDatabase
CategoryDao.Table.onDelete(db)
dbOpenHelper.deleteTable(
db,
DBOpenHelper.CONTRIBUTIONS_TABLE
) //Delete the contributions table in the existing db on older versions
dbOpenHelper.deleteTable(
db,
DBOpenHelper.BOOKMARKS_LOCATIONS
)
try {
contributionDao.deleteAll()
} catch (e: SQLiteException) {
Timber.e(e)
}
BookmarksTable.onDelete(db)
BookmarkItemsTable.onDelete(db)
}
/**
* Interface used to get log-out events
*/
interface LogoutListener {
fun onLogoutComplete()
}
/**
* This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
* with relevant intent parameters. It does not perform the actual logout operation.
*/
open class BaseLogoutListener : LogoutListener {
var ctx: Context
var loginMessage: String? = null
var userName: String? = null
/**
* Constructor for BaseLogoutListener.
*
* @param ctx Application context
*/
constructor(ctx: Context) {
this.ctx = ctx
}
/**
* Constructor for BaseLogoutListener
*
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page
* @param loginUsername Username to be pre-filled on the login page
*/
constructor(
ctx: Context, loginMessage: String?,
loginUsername: String?
) {
this.ctx = ctx
this.loginMessage = loginMessage
this.userName = loginUsername
}
override fun onLogoutComplete() {
Timber.d("Logout complete callback received.")
val loginIntent = Intent(ctx, LoginActivity::class.java)
loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (loginMessage != null) {
loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage)
}
if (userName != null) {
loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName)
}
ctx.startActivity(loginIntent)
}
}
/**
* This class is an extension of BaseLogoutListener, providing additional functionality or customization
* for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
*/
class ActivityLogoutListener : BaseLogoutListener {
var activity: Activity
/**
* Constructor for ActivityLogoutListener.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
*/
constructor(activity: Activity, ctx: Context) : super(ctx) {
this.activity = activity
}
/**
* Constructor for ActivityLogoutListener with additional parameters for the login screen.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page after logout.
* @param loginUsername Username to be pre-filled on the login page after logout.
*/
constructor(
activity: Activity, ctx: Context?,
loginMessage: String?, loginUsername: String?
) : super(activity, loginMessage, loginUsername) {
this.activity = activity
}
override fun onLogoutComplete() {
super.onLogoutComplete()
activity.finish()
}
}
companion object {
const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage"
const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername"
const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled"
/**
* Constants begin
*/
const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001
const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]"
const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com"
const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback"
const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com"
const val REPORT_EMAIL_SUBJECT: String = "Report a violation"
const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll"
const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --"
/**
* Constants End
*/
@JvmStatic
lateinit var instance: CommonsApplication
private set
@JvmField
var isPaused: Boolean = false
@JvmStatic
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context
.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
var channel = manager
.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL)
if (channel == null) {
channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID_ALL,
context.getString(R.string.notifications_channel_name_all),
NotificationManager.IMPORTANCE_DEFAULT
)
manager.createNotificationChannel(channel)
}
}
}
}
}

View file

@ -0,0 +1,74 @@
package fr.free.nrw.commons;
import android.content.Intent;
import android.os.Binder;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import fr.free.nrw.commons.di.CommonsDaggerService;
public abstract class HandlerService<T> extends CommonsDaggerService {
private volatile Looper threadLooper;
private volatile ServiceHandler threadHandler;
private String serviceName;
private final class ServiceHandler extends Handler {
public ServiceHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
//FIXME: Google Photos bug
handle(msg.what, (T)msg.obj);
stopSelf(msg.arg1);
}
}
@Override
public void onDestroy() {
threadLooper.quit();
super.onDestroy();
}
public class HandlerServiceLocalBinder extends Binder {
public HandlerService getService() {
return HandlerService.this;
}
}
private final IBinder localBinder = new HandlerServiceLocalBinder();
@Override
public IBinder onBind(Intent intent) {
return localBinder;
}
protected HandlerService(String serviceName) {
this.serviceName = serviceName;
}
@Override
public void onCreate() {
super.onCreate();
HandlerThread thread = new HandlerThread(serviceName);
thread.start();
threadLooper = thread.getLooper();
threadHandler = new ServiceHandler(threadLooper);
}
private void postMessage(int type, T t) {
Message msg = threadHandler.obtainMessage(type);
msg.obj = t;
threadHandler.sendMessage(msg);
}
public void queue(int what, T t) {
postMessage(what, t);
}
protected abstract void handle(int what, T t);
}

View file

@ -0,0 +1,79 @@
package fr.free.nrw.commons;
import androidx.annotation.Nullable;
/**
* represents Licence object
*/
public class License {
private String key;
private String template;
private String url;
private String name;
/**
* Constructs a new instance of License.
*
* @param key license key
* @param template license template
* @param url license URL
* @param name licence name
*
* @throws RuntimeException if License.key or Licence.template is null
*/
public License(String key, String template, String url, String name) {
if (key == null) {
throw new RuntimeException("License.key must not be null");
}
if (template == null) {
throw new RuntimeException("License.template must not be null");
}
this.key = key;
this.template = template;
this.url = url;
this.name = name;
}
/**
* Gets the license key.
* @return license key as a String.
*/
public String getKey() {
return key;
}
/**
* Gets the license template.
* @return license template as a String.
*/
public String getTemplate() {
return template;
}
/**
* Gets the license name. If name is null, return license key.
* @return license name as string. if name null, license key as String
*/
public String getName() {
if (name == null) {
// hack
return getKey();
} else {
return name;
}
}
/**
* Gets the license URL
*
* @param language license language
* @return URL
*/
public @Nullable String getUrl(String language) {
if (url == null) {
return null;
} else {
return url.replace("$lang", language);
}
}
}

View file

@ -1,46 +0,0 @@
package fr.free.nrw.commons
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
abstract class MapController {
/**
* We pass this variable as a group of placeList and boundaryCoordinates
*/
inner class NearbyPlacesInfo {
@JvmField
var placeList: List<Place> = emptyList() // List of nearby places
@JvmField
var boundaryCoordinates: Array<LatLng> = emptyArray() // Corners of nearby area
@JvmField
var currentLatLng: LatLng? = null // Current location when this places are populated
@JvmField
var searchLatLng: LatLng? = null // Search location for finding this places
@JvmField
var mediaList: List<Media>? = null // Search location for finding this places
}
/**
* We pass this variable as a group of placeList and boundaryCoordinates
*/
inner class ExplorePlacesInfo {
@JvmField
var explorePlaceList: List<Place> = emptyList() // List of nearby places
@JvmField
var boundaryCoordinates: Array<LatLng> = emptyArray() // Corners of nearby area
@JvmField
var currentLatLng: LatLng? = null // Current location when this places are populated
@JvmField
var searchLatLng: LatLng? = null // Search location for finding this places
@JvmField
var mediaList: List<Media> = emptyList() // Search location for finding this places
}
}

View file

@ -0,0 +1,568 @@
package fr.free.nrw.commons;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.dataclient.mwapi.MwQueryPage;
import org.wikipedia.gallery.ExtMetadata;
import org.wikipedia.gallery.ImageInfo;
import org.wikipedia.page.PageTitle;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.utils.MediaDataExtractorUtil;
@Entity
public class Media implements Parcelable {
public static final Media EMPTY = new Media("");
// Primary metadata fields
@Nullable
public Uri localUri;
public String thumbUrl;
public String imageUrl;
public String filename;
public String description; // monolingual description on input...
public String discussion;
long dataLength;
public Date dateCreated;
@Nullable public Date dateUploaded;
public int width;
public int height;
public String license;
public String licenseUrl;
public String creator;
public ArrayList<String> categories; // as loaded at runtime?
public boolean requestedDeletion;
public HashMap<String, String> descriptions; // multilingual descriptions as loaded
public HashMap<String, String> tags = new HashMap<>();
@Nullable public LatLng coordinates;
/**
* Provides local constructor
*/
protected Media() {
this.categories = new ArrayList<>();
this.descriptions = new HashMap<>();
}
/**
* Provides a minimal constructor
*
* @param filename Media filename
*/
public Media(String filename) {
this();
this.filename = filename;
}
/**
* Provide Media constructor
* @param localUri Media URI
* @param imageUrl Media image URL
* @param filename Media filename
* @param description Media description
* @param dataLength Media date length
* @param dateCreated Media creation date
* @param dateUploaded Media date uploaded
* @param creator Media creator
*/
public Media(Uri localUri, String imageUrl, String filename, String description,
long dataLength, Date dateCreated, Date dateUploaded, String creator) {
this();
this.localUri = localUri;
this.thumbUrl = imageUrl;
this.imageUrl = imageUrl;
this.filename = filename;
this.description = description;
this.dataLength = dataLength;
this.dateCreated = dateCreated;
this.dateUploaded = dateUploaded;
this.creator = creator;
this.categories = new ArrayList<>();
this.descriptions = new HashMap<>();
}
/**
* Creating Media object from MWQueryPage.
* Earlier only basic details were set for the media object but going forward,
* a full media object(with categories, descriptions, coordinates etc) can be constructed using this method
*
* @param page response from the API
* @return Media object
*/
@Nullable
public static Media from(MwQueryPage page) {
ImageInfo imageInfo = page.imageInfo();
if (imageInfo == null) {
return null;
}
ExtMetadata metadata = imageInfo.getMetadata();
if (metadata == null) {
Media media = new Media(null, imageInfo.getOriginalUrl(),
page.title(), "", 0, null, null, null);
if (!StringUtils.isBlank(imageInfo.getThumbUrl())) {
media.setThumbUrl(imageInfo.getThumbUrl());
}
return media;
}
Media media = new Media(null,
imageInfo.getOriginalUrl(),
page.title(),
"",
0,
safeParseDate(metadata.dateTime()),
safeParseDate(metadata.dateTime()),
getArtist(metadata)
);
if (!StringUtils.isBlank(imageInfo.getThumbUrl())) {
media.setThumbUrl(imageInfo.getThumbUrl());
}
String language = Locale.getDefault().getLanguage();
if (StringUtils.isBlank(language)) {
language = "default";
}
media.setDescriptions(Collections.singletonMap(language, metadata.imageDescription()));
media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.getCategories()));
String latitude = metadata.getGpsLatitude();
String longitude = metadata.getGpsLongitude();
if (!StringUtils.isBlank(latitude) && !StringUtils.isBlank(longitude)) {
LatLng latLng = new LatLng(Double.parseDouble(latitude), Double.parseDouble(longitude), 0);
media.setCoordinates(latLng);
}
media.setLicenseInformation(metadata.licenseShortName(), metadata.licenseUrl());
return media;
}
/**
* This method extracts the Commons Username from the artist HTML information
* @param metadata
* @return
*/
private static String getArtist(ExtMetadata metadata) {
try {
String artistHtml = metadata.artist();
return artistHtml.substring(artistHtml.indexOf("title=\""), artistHtml.indexOf("\">"))
.replace("title=\"User:", "");
} catch (Exception ex) {
return "";
}
}
public String getThumbUrl() {
return thumbUrl;
}
/**
* Gets tag of media
* @param key Media key
* @return Media tag
*/
public Object getTag(String key) {
return tags.get(key);
}
/**
* Modifies( or creates a) tag of media
* @param key Media key
* @param value Media value
*/
public void setTag(String key, String value) {
tags.put(key, value);
}
/**
* Gets media display title
* @return Media title
*/
@NonNull public String getDisplayTitle() {
return filename != null ? getPageTitle().getDisplayTextWithoutNamespace().replaceFirst("[.][^.]+$", "") : "";
}
/**
* Gets file page title
* @return New media page title
*/
@NonNull public PageTitle getPageTitle() {
return Utils.getPageTitle(getFilename());
}
/**
* Gets local URI
* @return Media local URI
*/
public Uri getLocalUri() {
return localUri;
}
/**
* Gets image URL
* can be null.
* @return Image URL
*/
@Nullable
public String getImageUrl() {
return imageUrl;
}
/**
* Gets the name of the file.
* @return file name as a string
*/
public String getFilename() {
return filename;
}
/**
* Sets the name of the file.
* @param filename the new name of the file
*/
public void setFilename(String filename) {
this.filename = filename;
}
/**
* Sets the discussion of the file.
* @param discussion
*/
public void setDiscussion(String discussion) {
this.discussion = discussion;
}
/**
* Gets the file discussion as a string.
* @return file discussion as a string
*/
public String getDiscussion() {
return discussion;
}
/**
* Gets the file description.
* @return file description as a string
*/
public String getDescription() {
return description;
}
/**
* Sets the file description.
* @param description the new description of the file
*/
public void setDescription(String description) {
this.description = description;
}
/**
* Gets the dataLength of the file.
* @return file dataLength as a long
*/
public long getDataLength() {
return dataLength;
}
/**
* Sets the dataLength of the file.
* @param dataLength as a long
*/
public void setDataLength(long dataLength) {
this.dataLength = dataLength;
}
/**
* Gets the creation date of the file.
* @return creation date as a Date
*/
public Date getDateCreated() {
return dateCreated;
}
/**
* Sets the creation date of the file.
* @param date creation date as a Date
*/
public void setDateCreated(Date date) {
this.dateCreated = date;
}
/**
* Gets the upload date of the file.
* Can be null.
* @return upload date as a Date
*/
public @Nullable
Date getDateUploaded() {
return dateUploaded;
}
/**
* Gets the name of the creator of the file.
* @return creator name as a String
*/
public String getCreator() {
return creator;
}
/**
* Sets the creator name of the file.
* @param creator creator name as a string
*/
public void setCreator(String creator) {
this.creator = creator;
}
/**
* Gets the width of the media.
* @return file width as an int
*/
public int getWidth() {
return width;
}
/**
* Sets the width of the media.
* @param width file width as an int
*/
public void setWidth(int width) {
this.width = width;
}
/**
* Gets the height of the media.
* @return file height as an int
*/
public int getHeight() {
return height;
}
/**
* Sets the height of the media.
* @param height file height as an int
*/
public void setHeight(int height) {
this.height = height;
}
/**
* Gets the license name of the file.
* @return license as a String
*/
public String getLicense() {
return license;
}
public void setThumbUrl(String thumbUrl) {
this.thumbUrl = thumbUrl;
}
public String getLicenseUrl() {
return licenseUrl;
}
/**
* Sets the license name of the file.
* @param license license name as a String
*/
public void setLicenseInformation(String license, String licenseUrl) {
this.license = license;
if (!licenseUrl.startsWith("http://") && !licenseUrl.startsWith("https://")) {
licenseUrl = "https://" + licenseUrl;
}
this.licenseUrl = licenseUrl;
}
/**
* Gets the coordinates of where the file was created.
* @return file coordinates as a LatLng
*/
public @Nullable
LatLng getCoordinates() {
return coordinates;
}
/**
* Sets the coordinates of where the file was created.
* @param coordinates file coordinates as a LatLng
*/
public void setCoordinates(@Nullable LatLng coordinates) {
this.coordinates = coordinates;
}
/**
* Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings
*/
@SuppressWarnings("unchecked")
public ArrayList<String> getCategories() {
return (ArrayList<String>) categories.clone(); // feels dirty
}
/**
* Sets the categories the file falls under.
* </p>
* Does not append: i.e. will clear the current categories
* and then add the specified ones.
* @param categories file categories as a list of Strings
*/
public void setCategories(List<String> categories) {
this.categories.clear();
this.categories.addAll(categories);
}
/**
* Modifies (or sets) media descriptions
* @param descriptions Media descriptions
*/
void setDescriptions(Map<String, String> descriptions) {
this.descriptions.clear();
this.descriptions.putAll(descriptions);
}
/**
* Gets media description in preferred language
* @param preferredLanguage Language preferred
* @return Description in preferred language
*/
public String getDescription(String preferredLanguage) {
if (descriptions.containsKey(preferredLanguage)) {
// See if the requested language is there.
return descriptions.get(preferredLanguage);
} else if (descriptions.containsKey("en")) {
// Ah, English. Language of the world, until the Chinese crush us.
return descriptions.get("en");
} else if (descriptions.containsKey("default")) {
// No languages marked...
return descriptions.get("default");
} else {
// FIXME: return the first available non-English description?
return "";
}
}
@Nullable private static Date safeParseDate(String dateStr) {
try {
return CommonsDateUtil.getIso8601DateFormatShort().parse(dateStr);
} catch (ParseException e) {
return null;
}
}
/**
* Set requested deletion to true
*/
public void setRequestedDeletion(){
requestedDeletion = true;
}
/**
* Get the value of requested deletion
* @return boolean requestedDeletion
*/
public boolean getRequestedDeletion(){
return requestedDeletion;
}
/**
* Sets the license name of the file.
*
* @param license license name as a String
*/
public void setLicense(String license) {
this.license = license;
}
@Override
public int describeContents() {
return 0;
}
/**
* Creates a way to transfer information between two or more
* activities.
* @param dest Instance of Parcel
* @param flags Parcel flag
*/
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(this.localUri, flags);
dest.writeString(this.thumbUrl);
dest.writeString(this.imageUrl);
dest.writeString(this.filename);
dest.writeString(this.description);
dest.writeString(this.discussion);
dest.writeLong(this.dataLength);
dest.writeLong(this.dateCreated != null ? this.dateCreated.getTime() : -1);
dest.writeLong(this.dateUploaded != null ? this.dateUploaded.getTime() : -1);
dest.writeInt(this.width);
dest.writeInt(this.height);
dest.writeString(this.license);
dest.writeString(this.licenseUrl);
dest.writeString(this.creator);
dest.writeStringList(this.categories);
dest.writeByte(this.requestedDeletion ? (byte) 1 : (byte) 0);
dest.writeSerializable(this.descriptions);
dest.writeSerializable(this.tags);
dest.writeParcelable(this.coordinates, flags);
}
protected Media(Parcel in) {
this.localUri = in.readParcelable(Uri.class.getClassLoader());
this.thumbUrl = in.readString();
this.imageUrl = in.readString();
this.filename = in.readString();
this.description = in.readString();
this.discussion = in.readString();
this.dataLength = in.readLong();
long tmpDateCreated = in.readLong();
this.dateCreated = tmpDateCreated == -1 ? null : new Date(tmpDateCreated);
long tmpDateUploaded = in.readLong();
this.dateUploaded = tmpDateUploaded == -1 ? null : new Date(tmpDateUploaded);
this.width = in.readInt();
this.height = in.readInt();
this.license = in.readString();
this.licenseUrl = in.readString();
this.creator = in.readString();
this.categories = in.createStringArrayList();
this.requestedDeletion = in.readByte() != 0;
this.descriptions = (HashMap<String, String>) in.readSerializable();
this.tags = (HashMap<String, String>) in.readSerializable();
this.coordinates = in.readParcelable(LatLng.class.getClassLoader());
}
public static final Creator<Media> CREATOR = new Creator<Media>() {
@Override
public Media createFromParcel(Parcel source) {
return new Media(source);
}
@Override
public Media[] newArray(int size) {
return new Media[size];
}
};
}

View file

@ -1,206 +0,0 @@
package fr.free.nrw.commons
import android.os.Parcelable
import fr.free.nrw.commons.BuildConfig.COMMONS_URL
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.wikidata.model.WikiSite
import fr.free.nrw.commons.wikidata.model.page.PageTitle
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.Date
import java.util.Locale
import java.util.UUID
@Parcelize
class Media constructor(
/**
* @return pageId for the current media object
* Wikibase Identifier associated with media files
*/
var pageId: String = UUID.randomUUID().toString(),
var thumbUrl: String? = null,
/**
* Gets image URL
* @return Image URL
*/
var imageUrl: String? = null,
/**
* Gets the name of the file.
* @return file name as a string
*/
var filename: String? = null,
/**
* The fallback description of the file, used if no other description is provided.
*/
var fallbackDescription: String? = null,
/**
* Gets the upload date of the file.
* Can be null.
* @return upload date as a Date
*/
var dateUploaded: Date? = null,
/**
* The license name of the file.
*/
var license: String? = null,
/**
* The URL corresponding to the license.
*/
var licenseUrl: String? = null,
/**
* The name of the creator of the file.
*/
var author: String? = null,
/**
* The username of the uploader.
*/
var user: String? = null,
/**
* The full name of the file's creator, if different from username.
*/
var creatorName: String? = null,
/**
* Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings
*/
var categories: List<String>? = null,
/**
* Gets the coordinates of where the file was created.
* @return file coordinates as a LatLng
*/
var coordinates: LatLng? = null,
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
* Example: "Mountains" => false, "CC-BY-SA-2.0" => true
*/
var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(),
) : Parcelable {
constructor(
captions: Map<String, String>,
categories: List<String>?,
filename: String?,
fallbackDescription: String?,
author: String?,
user: String?,
) : this(
filename = filename,
fallbackDescription = fallbackDescription,
dateUploaded = Date(),
author = author,
user = user,
categories = categories,
captions = captions,
)
constructor(
captions: Map<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
*/
val displayTitle: String
get() =
if (filename != null) {
pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
} else {
""
}
/**
* Gets file page title
* @return New media page title
*/
val pageTitle: PageTitle
get() = PageTitle(filename!!, WikiSite(COMMONS_URL))
/**
* Returns wikicode to use the media file on a MediaWiki site
* @return
*/
val wikiCode: String
get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
val mostRelevantCaption: String
get() =
captions[Locale.getDefault().language]
?: captions.values.firstOrNull()
?: displayTitle
/**
* Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings
*/
@IgnoredOnParcel
var addedCategories: List<String>? = null
// TODO added categories should be removed. It is added for a short fix. On category update,
// categories should be re-fetched instead
get() = field // getter
set(value) {
field = value
} // setter
}

View file

@ -0,0 +1,68 @@
package fr.free.nrw.commons;
import androidx.core.text.HtmlCompat;
import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.media.MediaClient;
import io.reactivex.Single;
import timber.log.Timber;
/**
* Fetch additional media data from the network that we don't store locally.
*
* This includes things like category lists and multilingual descriptions,
* which are not intrinsic to the media and may change due to editing.
*/
@Singleton
public class MediaDataExtractor {
private final MediaClient mediaClient;
@Inject
public MediaDataExtractor(MediaClient mediaClient) {
this.mediaClient = mediaClient;
}
/**
* Simplified method to extract all details required to show media details.
* It fetches media object, deletion status and talk page for the filename
* @param filename for which the details are to be fetched
* @return full Media object with all details including deletion status and talk page
*/
public Single<Media> fetchMediaDetails(String filename) {
Single<Media> mediaSingle = getMediaFromFileName(filename);
Single<Boolean> pageExistsSingle = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + filename);
Single<String> discussionSingle = getDiscussion(filename);
return Single.zip(mediaSingle, pageExistsSingle, discussionSingle, (media, deletionStatus, discussion) -> {
media.setDiscussion(discussion);
if (deletionStatus) {
media.setRequestedDeletion();
}
return media;
});
}
/**
* Method can be used to fetch media for a given filename
* @param filename Eg. File:Test.jpg
* @return return data rich Media object
*/
public Single<Media> getMediaFromFileName(String filename) {
return mediaClient.getMedia(filename);
}
/**
* Fetch talk page from the MediaWiki API
* @param filename
* @return
*/
private Single<String> getDiscussion(String filename) {
return mediaClient.getPageHtml(filename.replace("File", "File talk"))
.map(discussion -> HtmlCompat.fromHtml(discussion, HtmlCompat.FROM_HTML_MODE_LEGACY).toString())
.onErrorReturn(throwable -> {
Timber.e(throwable, "Error occurred while fetching discussion");
return "";
});
}
}

View file

@ -1,72 +0,0 @@
package fr.free.nrw.commons
import androidx.core.text.HtmlCompat
import fr.free.nrw.commons.media.IdAndLabels
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import io.reactivex.Single
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Fetch additional media data from the network that we don't store locally.
*
*
* This includes things like category lists and multilingual descriptions, which are not intrinsic
* to the media and may change due to editing.
*/
@Singleton
class MediaDataExtractor
@Inject
constructor(
private val mediaClient: MediaClient,
) {
fun fetchDepictionIdsAndLabels(media: Media) =
mediaClient
.getEntities(media.depictionIds)
.map {
it
.entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}.map { it.map { (key, value) -> IdAndLabels(key, value) } }
.onErrorReturn { emptyList() }
fun fetchCreatorIdsAndLabels(media: Media) =
mediaClient
.getEntities(media.creatorIds)
.map {
it
.entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}.map { it.map { (key, value) -> IdAndLabels(key, value) } }
.onErrorReturn { emptyList() }
fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
fun fetchDiscussion(media: Media) =
mediaClient
.getPageHtml(media.filename!!.replace("File", "File talk"))
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
.onErrorReturn {
Timber.d("Error occurred while fetching discussion")
""
}
fun refresh(media: Media): Single<Media> =
Single.ambArray(
mediaClient
.getMediaById(PAGE_ID_PREFIX + media.pageId)
.onErrorResumeNext { Single.never() },
mediaClient
.getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() },
)
fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
/**
* Fetches wikitext from mediaClient
*/
fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title)
}

View file

@ -0,0 +1,8 @@
package fr.free.nrw.commons;
/**
* Base interface for all the views
*/
public interface MvpView {
void showMessage(String message);
}

View file

@ -0,0 +1,72 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
import org.wikipedia.dataclient.okhttp.HttpStatusException;
import java.io.File;
import java.io.IOException;
import okhttp3.Cache;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;
public final class OkHttpConnectionFactory {
private static final String CACHE_DIR_NAME = "okhttp-cache";
private static final long NET_CACHE_SIZE = 64 * 1024 * 1024;
@NonNull private static final Cache NET_CACHE = new Cache(new File(CommonsApplication.getInstance().getCacheDir(),
CACHE_DIR_NAME), NET_CACHE_SIZE);
@NonNull private static OkHttpClient CLIENT = createClient();
@NonNull public static OkHttpClient getClient() {
return CLIENT;
}
@NonNull
private static OkHttpClient createClient() {
return new OkHttpClient.Builder()
.cookieJar(SharedPreferenceCookieManager.getInstance())
.cache(NET_CACHE)
.addInterceptor(getLoggingInterceptor())
.addInterceptor(new UnsuccessfulResponseInterceptor())
.addInterceptor(new CommonHeaderRequestInterceptor())
.build();
}
private static HttpLoggingInterceptor getLoggingInterceptor() {
HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BASIC);
httpLoggingInterceptor.redactHeader("Authorization");
httpLoggingInterceptor.redactHeader("Cookie");
return httpLoggingInterceptor;
}
private static class CommonHeaderRequestInterceptor implements Interceptor {
@Override @NonNull public Response intercept(@NonNull Chain chain) throws IOException {
Request request = chain.request().newBuilder()
.header("User-Agent", CommonsApplication.getInstance().getUserAgent())
.build();
return chain.proceed(request);
}
}
public static class UnsuccessfulResponseInterceptor implements Interceptor {
@Override @NonNull public Response intercept(@NonNull Chain chain) throws IOException {
Response rsp = chain.proceed(chain.request());
if (rsp.isSuccessful()) {
return rsp;
}
throw new HttpStatusException(rsp);
}
}
private OkHttpConnectionFactory() {
}
}

View file

@ -1,135 +0,0 @@
package fr.free.nrw.commons
import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.wikidata.GsonUtil
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
import fr.free.nrw.commons.wikidata.mwapi.MwErrorResponse
import fr.free.nrw.commons.wikidata.mwapi.MwIOException
import fr.free.nrw.commons.wikidata.mwapi.MwLegacyServiceError
import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
object OkHttpConnectionFactory {
private const val CACHE_DIR_NAME = "okhttp-cache"
private const val NET_CACHE_SIZE = (64 * 1024 * 1024).toLong()
@VisibleForTesting
var CLIENT: OkHttpClient? = null
fun getClient(cookieJar: CommonsCookieJar): OkHttpClient {
if (CLIENT == null) {
CLIENT = createClient(cookieJar)
}
return CLIENT!!
}
private fun createClient(cookieJar: CommonsCookieJar): OkHttpClient {
return OkHttpClient.Builder()
.cookieJar(cookieJar)
.cache(
if (CommonsApplication.instance != null) Cache(
File(CommonsApplication.instance.cacheDir, CACHE_DIR_NAME),
NET_CACHE_SIZE
) else null
)
.connectTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
setLevel(HttpLoggingInterceptor.Level.BASIC)
redactHeader("Authorization")
redactHeader("Cookie")
})
.addInterceptor(UnsuccessfulResponseInterceptor())
.addInterceptor(CommonHeaderRequestInterceptor())
.build()
}
}
class CommonHeaderRequestInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("User-Agent", CommonsApplication.instance.userAgent)
.build()
return chain.proceed(request)
}
}
private const val SUPPRESS_ERROR_LOG = "x-commons-suppress-error-log"
const val SUPPRESS_ERROR_LOG_HEADER: String = "$SUPPRESS_ERROR_LOG: true"
private class UnsuccessfulResponseInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val rq = chain.request()
// If the request contains our special "suppress errors" header, make note of it
// but don't pass that on to the server.
val suppressErrors = rq.headers.names().contains(SUPPRESS_ERROR_LOG)
val request = rq.newBuilder()
.removeHeader(SUPPRESS_ERROR_LOG)
.build()
val rsp = chain.proceed(request)
// Do not intercept certain requests and let the caller handle the errors
if (isExcludedUrl(chain.request())) {
return rsp
}
if (rsp.isSuccessful) {
try {
rsp.peekBody(ERRORS_PREFIX.length.toLong()).use { responseBody ->
if (ERRORS_PREFIX == responseBody.string()) {
rsp.body.use { body ->
val bodyString = body!!.string()
throw MwIOException(
"MediaWiki API returned error: $bodyString",
GsonUtil.defaultGson.fromJson(
bodyString,
MwErrorResponse::class.java
).error!!,
)
}
}
}
} catch (e: MwIOException) {
// Log the error as debug (and therefore, "expected") or at error level
if (suppressErrors) {
Timber.d(e, "Suppressed (known / expected) error")
} else {
Timber.e(e)
throw e
}
}
return rsp
}
throw IOException("Unsuccessful response")
}
private fun isExcludedUrl(request: Request): Boolean {
val requestUrl = request.url.toString()
for (url in DO_NOT_INTERCEPT) {
if (requestUrl.contains(url)) {
return true
}
}
return false
}
companion object {
val DO_NOT_INTERCEPT = listOf(
"api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1"
)
const val ERRORS_PREFIX = "{\"error"
}
}

View file

@ -3,16 +3,12 @@ package fr.free.nrw.commons
internal object Urls {
const val NEW_ISSUE_URL = "https://github.com/commons-app/apps-android-commons/issues"
const val GITHUB_REPO_URL = "https://github.com/commons-app/apps-android-commons"
const val GITHUB_PACKAGE_NAME = "com.github.android"
const val WEBSITE_URL = "https://commons-app.github.io"
const val CREDITS_URL = "https://github.com/commons-app/apps-android-commons/blob/master/CREDITS"
const val USER_GUIDE_URL = "https://commons-app.github.io/docs.html"
const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md"
const val PLAY_STORE_PREFIX = "market://details?id="
const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id="
const val TRANSLATE_WIKI_URL =
"https://translatewiki.net/w/i.php?title=Special:Translate" +
"&group=commons-android-strings&filter=%21translated&action=translate&language="
const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language="
const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985"
const val FACEBOOK_APP_URL = "fb://page/1921335171459985"
const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"

View file

@ -0,0 +1,204 @@
package fr.free.nrw.commons;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.text.SpannableString;
import android.text.style.UnderlineSpan;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.content.ContextCompat;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.page.PageTitle;
import java.util.Locale;
import java.util.regex.Pattern;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.utils.ViewUtil;
import timber.log.Timber;
import static android.widget.Toast.LENGTH_SHORT;
public class Utils {
public static PageTitle getPageTitle(@NonNull String title) {
return new PageTitle(title, new WikiSite(BuildConfig.COMMONS_URL));
}
/**
* Generates licence name with given ID
* @param license License ID
* @return Name of license
*/
public static int licenseNameFor(String license) {
switch (license) {
case Prefs.Licenses.CC_BY_3:
return R.string.license_name_cc_by;
case Prefs.Licenses.CC_BY_4:
return R.string.license_name_cc_by_four;
case Prefs.Licenses.CC_BY_SA_3:
return R.string.license_name_cc_by_sa;
case Prefs.Licenses.CC_BY_SA_4:
return R.string.license_name_cc_by_sa_four;
case Prefs.Licenses.CC0:
return R.string.license_name_cc0;
}
throw new IllegalStateException("Unrecognized license value: " + license);
}
/**
* Generates license url with given ID
* @param license License ID
* @return Url of license
*/
@NonNull
public static String licenseUrlFor(String license) {
switch (license) {
case Prefs.Licenses.CC_BY_3:
return "https://creativecommons.org/licenses/by/3.0/";
case Prefs.Licenses.CC_BY_4:
return "https://creativecommons.org/licenses/by/4.0/";
case Prefs.Licenses.CC_BY_SA_3:
return "https://creativecommons.org/licenses/by-sa/3.0/";
case Prefs.Licenses.CC_BY_SA_4:
return "https://creativecommons.org/licenses/by-sa/4.0/";
case Prefs.Licenses.CC0:
return "https://creativecommons.org/publicdomain/zero/1.0/";
default:
throw new IllegalStateException("Unrecognized license value: " + license);
}
}
/**
* Adds extension to filename. Converts to .jpg if system provides .jpeg, adds .jpg if no extension detected
* @param title File name
* @param extension Correct extension
* @return File with correct extension
*/
public static String fixExtension(String title, String extension) {
Pattern jpegPattern = Pattern.compile("\\.jpeg$", Pattern.CASE_INSENSITIVE);
// People are used to ".jpg" more than ".jpeg" which the system gives us.
if (extension != null && extension.toLowerCase(Locale.ENGLISH).equals("jpeg")) {
extension = "jpg";
}
title = jpegPattern.matcher(title).replaceFirst(".jpg");
if (extension != null && !title.toLowerCase(Locale.getDefault())
.endsWith("." + extension.toLowerCase(Locale.ENGLISH))) {
title += "." + extension;
}
// If extension is still null, make it jpg. (Hotfix for https://github.com/commons-app/apps-android-commons/issues/228)
// If title has an extension in it, if won't be true
if (extension == null && title.lastIndexOf(".")<=0) {
extension = "jpg";
title += "." + extension;
}
return title;
}
/**
* Launches intent to rate app
* @param context
*/
public static void rateApp(Context context) {
final String appPackageName = context.getPackageName();
try {
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.PLAY_STORE_PREFIX + appPackageName)));
}
catch (android.content.ActivityNotFoundException anfe) {
handleWebUrl(context, Uri.parse(Urls.PLAY_STORE_URL_PREFIX + appPackageName));
}
}
/**
* Opens Custom Tab Activity with in-app browser for the specified URL.
* Launches intent for web URL
* @param context
* @param url
*/
public static void handleWebUrl(Context context, Uri url) {
Timber.d("Launching web url %s", url.toString());
Intent browserIntent = new Intent(Intent.ACTION_VIEW, url);
if (browserIntent.resolveActivity(context.getPackageManager()) == null) {
Toast toast = Toast.makeText(context, context.getString(R.string.no_web_browser), LENGTH_SHORT);
toast.show();
return;
}
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
builder.setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor));
builder.setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.primaryDarkColor));
builder.setExitAnimations(context, android.R.anim.slide_in_left, android.R.anim.slide_out_right);
CustomTabsIntent customTabsIntent = builder.build();
// Clear previous browser tasks, so that back/exit buttons work as intended.
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
customTabsIntent.launchUrl(context, url);
}
/**
* 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
*/
public static void handleGeoCoordinates(Context context, LatLng latLng) {
Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri());
if (mapIntent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(mapIntent);
} else {
ViewUtil.showShortToast(context, context.getString(R.string.map_application_missing));
}
}
/**
* To take screenshot of the screen and return it in Bitmap format
*
* @param view
* @return
*/
public static Bitmap getScreenShot(View view) {
View screenView = view.getRootView();
screenView.setDrawingCacheEnabled(true);
Bitmap bitmap = Bitmap.createBitmap(screenView.getDrawingCache());
screenView.setDrawingCacheEnabled(false);
return bitmap;
}
/*
*Copies the content to the clipboard
*
*/
public static void copy(String label,String text, Context context){
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(label, text);
clipboard.setPrimaryClip(clip);
}
/**
* This method sets underlined string text to a TextView
*
* @param textView TextView associated with string resource
* @param stringResourceName string resource name
* @param context
*/
public static void setUnderlinedText(TextView textView, int stringResourceName, Context context) {
SpannableString content = new SpannableString(context.getString(stringResourceName));
content.setSpan(new UnderlineSpan(), 0, content.length(), 0);
textView.setText(content);
}
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons;
import android.content.Context;
public interface ViewHolder<T> {
void bindModel(Context context, T model);
}

View file

@ -1,44 +0,0 @@
package fr.free.nrw.commons
import android.content.Context
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import java.util.Locale
/**
* This adapter will be used to display fragments in a ViewPager
*/
class ViewPagerAdapter : FragmentPagerAdapter {
private val context: Context
private var fragmentList: List<Fragment> = emptyList()
private var fragmentTitleList: List<String> = emptyList()
constructor(context: Context, manager: FragmentManager) : super(manager) {
this.context = context
}
constructor(context: Context, manager: FragmentManager, behavior: Int) : super(manager, behavior) {
this.context = context
}
override fun getItem(position: Int): Fragment = fragmentList[position]
override fun getPageTitle(position: Int): CharSequence = fragmentTitleList[position]
override fun getCount(): Int = fragmentList.size
fun setTabs(vararg titlesToFragments: Pair<Int, Fragment>) {
// Enforce that every title must come from strings.xml and all will consistently be uppercase
fragmentTitleList = titlesToFragments.map {
context.getString(it.first).uppercase(Locale.ROOT)
}
fragmentList = titlesToFragments.map { it.second }
}
companion object {
// Convenience method for Java callers, can be removed when everything is migrated
@JvmStatic
fun pairOf(first: Int, second: Fragment) = first to second
}
}

View file

@ -0,0 +1,98 @@
package fr.free.nrw.commons;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import androidx.viewpager.widget.ViewPager;
import com.viewpagerindicator.CirclePageIndicator;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import fr.free.nrw.commons.quiz.QuizActivity;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.ConfigUtils;
public class WelcomeActivity extends BaseActivity {
@BindView(R.id.welcomePager)
ViewPager pager;
@BindView(R.id.welcomePagerIndicator)
CirclePageIndicator indicator;
private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
private boolean isQuiz;
/**
* Initialises exiting fields and dependencies
*
* @param savedInstanceState WelcomeActivity bundled data
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_welcome);
if (getIntent() != null) {
Bundle bundle = getIntent().getExtras();
if (bundle != null) {
isQuiz = bundle.getBoolean("isQuiz");
}
} else {
isQuiz = false;
}
// Enable skip button if beta flavor
if (ConfigUtils.isBetaFlavour()) {
findViewById(R.id.finishTutorialButton).setVisibility(View.VISIBLE);
}
ButterKnife.bind(this);
pager.setAdapter(adapter);
indicator.setViewPager(pager);
}
/**
* References WelcomePageAdapter to null before the activity is destroyed
*/
@Override
public void onDestroy() {
if (isQuiz) {
Intent i = new Intent(WelcomeActivity.this, QuizActivity.class);
startActivity(i);
}
super.onDestroy();
}
/**
* Creates a way to change current activity to WelcomeActivity
*
* @param context Activity context
*/
public static void startYourself(Context context) {
Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
context.startActivity(welcomeIntent);
}
/**
* Override onBackPressed() to go to previous tutorial 'pages' if not on first page
*/
@Override
public void onBackPressed() {
if (pager.getCurrentItem() != 0) {
pager.setCurrentItem(pager.getCurrentItem() - 1, true);
} else {
finish();
}
}
@OnClick(R.id.finishTutorialButton)
public void finishTutorial() {
defaultKvStore.putBoolean("firstrun", false);
finish();
}
}

View file

@ -1,80 +0,0 @@
package fr.free.nrw.commons
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import fr.free.nrw.commons.databinding.ActivityWelcomeBinding
import fr.free.nrw.commons.databinding.PopupForCopyrightBinding
import fr.free.nrw.commons.quiz.QuizActivity
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
class WelcomeActivity : BaseActivity() {
private var binding: ActivityWelcomeBinding? = null
private var isQuiz = false
/**
* Initialises exiting fields and dependencies
*
* @param savedInstanceState WelcomeActivity bundled data
*/
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityWelcomeBinding.inflate(layoutInflater)
applyEdgeToEdgeAllInsets(binding!!.welcomePager.rootView)
setContentView(binding!!.root)
isQuiz = intent?.extras?.getBoolean("isQuiz", false) ?: false
// Enable skip button if beta flavor
if (isBetaFlavour) {
binding!!.finishTutorialButton.visibility = View.VISIBLE
val copyrightBinding = PopupForCopyrightBinding.inflate(layoutInflater)
val dialog = AlertDialog.Builder(this)
.setView(copyrightBinding.root)
.setCancelable(false)
.create()
dialog.show()
copyrightBinding.buttonOk.setOnClickListener { v: View? -> dialog.dismiss() }
}
val adapter = WelcomePagerAdapter()
binding!!.welcomePager.adapter = adapter
binding!!.welcomePagerIndicator.setViewPager(binding!!.welcomePager)
binding!!.finishTutorialButton.setOnClickListener { v: View? -> finishTutorial() }
}
public override fun onDestroy() {
if (isQuiz) {
startActivity(Intent(this, QuizActivity::class.java))
}
super.onDestroy()
}
override fun onBackPressed() {
if (binding!!.welcomePager.currentItem != 0) {
binding!!.welcomePager.setCurrentItem(binding!!.welcomePager.currentItem - 1, true)
} else {
if (defaultKvStore.getBoolean("firstrun", true)) {
finishAffinity()
} else {
super.onBackPressed()
}
}
}
fun finishTutorial() {
defaultKvStore.putBoolean("firstrun", false)
finish()
}
}
fun Context.startWelcome() {
startActivity(Intent(this, WelcomeActivity::class.java))
}

View file

@ -0,0 +1,75 @@
package fr.free.nrw.commons;
import android.net.Uri;
import android.text.Html;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.viewpager.widget.PagerAdapter;
public class WelcomePagerAdapter extends PagerAdapter {
private static final int[] PAGE_LAYOUTS = new int[]{
R.layout.welcome_wikipedia,
R.layout.welcome_do_upload,
R.layout.welcome_dont_upload,
R.layout.welcome_image_example,
R.layout.welcome_final
};
/**
* Gets total number of layouts
* @return Number of layouts
*/
@Override
public int getCount() {
return PAGE_LAYOUTS.length;
}
/**
* Compares given view with provided object
* @param view Adapter view
* @param object Adapter object
* @return Equality between view and object
*/
@Override
public boolean isViewFromObject(View view, Object object) {
return (view == object);
}
@Override
public Object instantiateItem(ViewGroup container, int position) {
LayoutInflater inflater = LayoutInflater.from(container.getContext());
ViewGroup layout = (ViewGroup) inflater.inflate(PAGE_LAYOUTS[position], container, false);
// If final page
if (position == PAGE_LAYOUTS.length - 1) {
// Add link to more information
TextView moreInfo = layout.findViewById(R.id.welcomeInfo);
Utils.setUnderlinedText(moreInfo, R.string.welcome_help_button_text, container.getContext());
moreInfo.setOnClickListener(view -> Utils.handleWebUrl(
container.getContext(),
Uri.parse("https://commons.wikimedia.org/wiki/Help:Contents")
));
// Handle click of finishTutorialButton ("YES!" button) inside layout
layout.findViewById(R.id.finishTutorialButton)
.setOnClickListener(view -> ((WelcomeActivity) container.getContext()).finishTutorial());
}
container.addView(layout);
return layout;
}
/**
* Provides a way to remove an item from container
* @param container Adapter view group container
* @param position Index of item
* @param obj Adapter object
*/
@Override
public void destroyItem(ViewGroup container, int position, Object obj) {
container.removeView((View) obj);
}
}

View file

@ -1,70 +0,0 @@
package fr.free.nrw.commons
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.net.toUri
import androidx.viewpager.widget.PagerAdapter
import fr.free.nrw.commons.utils.UnderlineUtils.setUnderlinedText
import fr.free.nrw.commons.utils.handleWebUrl
class WelcomePagerAdapter : PagerAdapter() {
/**
* Gets total number of layouts
* @return Number of layouts
*/
override fun getCount(): Int = PAGE_LAYOUTS.size
/**
* Compares given view with provided object
* @param view Adapter view
* @param obj Adapter object
* @return Equality between view and object
*/
override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj)
/**
* Provides a way to remove an item from container
* @param container Adapter view group container
* @param position Index of item
* @param obj Adapter object
*/
override fun destroyItem(container: ViewGroup, position: Int, obj: Any) =
container.removeView(obj as View)
override fun instantiateItem(container: ViewGroup, position: Int): Any {
val inflater = LayoutInflater.from(container.context)
val layout = inflater.inflate(PAGE_LAYOUTS[position], container, false) as ViewGroup
// If final page
if (position == PAGE_LAYOUTS.size - 1) {
// Add link to more information
val moreInfo = layout.findViewById<TextView>(R.id.welcomeInfo)
setUnderlinedText(moreInfo, R.string.welcome_help_button_text)
moreInfo.setOnClickListener {
handleWebUrl(
container.context,
"https://commons.wikimedia.org/wiki/Help:Contents".toUri()
)
}
// Handle click of finishTutorialButton ("YES!" button) inside layout
layout.findViewById<View>(R.id.finishTutorialButton)
.setOnClickListener { view: View? -> (container.context as WelcomeActivity).finishTutorial() }
}
container.addView(layout)
return layout
}
companion object {
private val PAGE_LAYOUTS = intArrayOf(
R.layout.welcome_wikipedia,
R.layout.welcome_do_upload,
R.layout.welcome_dont_upload,
R.layout.welcome_image_example,
R.layout.welcome_final
)
}
}

View file

@ -0,0 +1,98 @@
package fr.free.nrw.commons.achievements
/**
* Represents Achievements class and stores all the parameters
*/
class Achievements {
/**
* getter function to get count of unique images used by wiki
* @return
*/
/**
* setter function to set count of uniques images used by wiki
* @param uniqueUsedImages
*/
var uniqueUsedImages = 0
private var articlesUsingImages = 0
/**
* getter function to get count of thanks received
* @return
*/
/**
* setter function to set count of thanks received
* @param thanksReceived
*/
var thanksReceived = 0
/**
* getter function to get count of featured images
* @return
*/
/**
* setter function to set count of featured images
* @param featuredImages
*/
var featuredImages = 0
/**
* getter function to get count of images uploaded
* @return
*/
/**
* setter function to count of images uploaded
* @param imagesUploaded
*/
var imagesUploaded = 0
private var revertCount = 0
constructor() {}
/**
* constructor for achievements class to set its data members
* @param uniqueUsedImages
* @param articlesUsingImages
* @param thanksReceived
* @param featuredImages
* @param imagesUploaded
* @param revertCount
*/
constructor(uniqueUsedImages: Int,
articlesUsingImages: Int,
thanksReceived: Int,
featuredImages: Int,
imagesUploaded: Int,
revertCount: Int) {
this.uniqueUsedImages = uniqueUsedImages
this.articlesUsingImages = articlesUsingImages
this.thanksReceived = thanksReceived
this.featuredImages = featuredImages
this.imagesUploaded = imagesUploaded
this.revertCount = revertCount
}
/**
* used to calculate the percentages of images that haven't been reverted
* @return
*/
val notRevertPercentage: Int
get() = try {
(imagesUploaded - revertCount) * 100 / imagesUploaded
} catch (divideByZero: ArithmeticException) {
100
}
companion object {
/**
* Get Achievements object from FeedbackResponse
*
* @param response
* @return
*/
@JvmStatic
fun from(response: FeedbackResponse): Achievements {
return Achievements(response.uniqueUsedImages,
response.articlesUsingImages,
response.thanksReceived,
response.featuredImages.qualityImages
+ response.featuredImages.featuredPicturesOnWikimediaCommons, 0,
response.deletedUploads)
}
}
}

View file

@ -0,0 +1,534 @@
package fr.free.nrw.commons.achievements;
import android.accounts.Account;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.FileProvider;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import com.dinuscxj.progressbar.CircleProgressBar;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Objects;
import javax.inject.Inject;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
/**
* activity for sharing feedback on uploaded activity
*/
public class AchievementsActivity extends NavigationBaseActivity {
private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4;
private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3;
private LevelController.LevelInfo levelInfo;
@BindView(R.id.achievement_badge_image)
ImageView imageView;
@BindView(R.id.achievement_badge_text)
TextView badgeText;
@BindView(R.id.achievement_level)
TextView levelNumber;
@BindView(R.id.toolbar)
Toolbar toolbar;
@BindView(R.id.thanks_received)
TextView thanksReceived;
@BindView(R.id.images_uploaded_progressbar)
CircleProgressBar imagesUploadedProgressbar;
@BindView(R.id.images_used_by_wiki_progress_bar)
CircleProgressBar imagesUsedByWikiProgressBar;
@BindView(R.id.image_reverts_progressbar)
CircleProgressBar imageRevertsProgressbar;
@BindView(R.id.image_featured)
TextView imagesFeatured;
@BindView(R.id.images_revert_limit_text)
TextView imagesRevertLimitText;
@BindView(R.id.progressBar)
ProgressBar progressBar;
@BindView(R.id.layout_image_uploaded)
RelativeLayout layoutImageUploaded;
@BindView(R.id.layout_image_reverts)
RelativeLayout layoutImageReverts;
@BindView(R.id.layout_image_used_by_wiki)
RelativeLayout layoutImageUsedByWiki;
@BindView(R.id.layout_statistics)
LinearLayout layoutStatistics;
@BindView(R.id.images_used_by_wiki_text)
TextView imageByWikiText;
@BindView(R.id.images_reverted_text)
TextView imageRevertedText;
@BindView(R.id.images_upload_text_param)
TextView imageUploadedText;
@BindView(R.id.wikidata_edits)
TextView wikidataEditsText;
@Inject
SessionManager sessionManager;
@Inject
OkHttpJsonApiClient okHttpJsonApiClient;
MenuItem item;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
// To keep track of the number of wiki edits made by a user
private int numberOfEdits = 0;
/**
* This method helps in the creation Achievement screen and
* dynamically set the size of imageView
*
* @param savedInstanceState Data bundle
*/
@Override
@SuppressLint("StringFormatInvalid")
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_achievements);
ButterKnife.bind(this);
// DisplayMetrics used to fetch the size of the screen
DisplayMetrics displayMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
int height = displayMetrics.heightPixels;
int width = displayMetrics.widthPixels;
// Used for the setting the size of imageView at runtime
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams)
imageView.getLayoutParams();
params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO);
params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO);
imageView.requestLayout();
setSupportActionBar(toolbar);
progressBar.setVisibility(View.VISIBLE);
hideLayouts();
setWikidataEditCount();
setAchievements();
initDrawer();
}
@Override
public void onDestroy() {
super.onDestroy();
compositeDisposable.clear();
}
/**
* To invoke the AlertDialog on clicking info button
*/
@OnClick(R.id.achievement_info)
public void showInfoDialog(){
launchAlert(getResources().getString(R.string.Achievements)
,getResources().getString(R.string.achievements_info_message));
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_about, menu);
item=menu.getItem(0);
item.setVisible(false);
return true;
}
/**
* To receive the id of selected item and handle further logic for that selected item
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
// take screenshot in form of bitmap and show it in Alert Dialog
if (id == R.id.share_app_icon) {
View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
Bitmap screenShot = Utils.getScreenShot(rootView);
showAlert(screenShot);
}
return super.onOptionsItemSelected(item);
}
/**
* To take bitmap and store it temporary storage and share it
* @param bitmap
*/
void shareScreen(Bitmap bitmap) {
try {
File file = new File(this.getExternalCacheDir(), "screen.png");
FileOutputStream fOut = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut);
fOut.flush();
fOut.close();
file.setReadable(true, false);
Uri fileUri = FileProvider.getUriForFile(getApplicationContext(), getPackageName()+".provider", file);
grantUriPermission(getPackageName(), fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(Intent.EXTRA_STREAM, fileUri);
intent.setType("image/png");
startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)));
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* To call the API to get results in form Single<JSONObject>
* which then calls parseJson when results are fetched
*/
private void setAchievements() {
progressBar.setVisibility(View.VISIBLE);
if (checkAccount()) {
try{
compositeDisposable.add(okHttpJsonApiClient
.getAchievements(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
response -> {
if (response != null) {
setUploadCount(Achievements.from(response));
} else {
Timber.d("success");
layoutImageReverts.setVisibility(View.INVISIBLE);
imageView.setVisibility(View.INVISIBLE);
// If the number of edits made by the user are more than 150,000
// in some cases such high number of wiki edit counts cause the
// achievements calculator to fail in some cases, for more details
// refer Issue: #3295
if (numberOfEdits <= 150000) {
showSnackBarWithRetry(false);
} else {
showSnackBarWithRetry(true);
}
}
},
t -> {
Timber.e(t, "Fetching achievements statistics failed");
if (numberOfEdits <= 150000) {
showSnackBarWithRetry(false);
} else {
showSnackBarWithRetry(true);
}
}
));
}
catch (Exception e){
Timber.d(e+"success");
}
}
}
/**
* To call the API to fetch the count of wiki data edits
* in the form of JavaRx Single object<JSONobject>
*/
@SuppressLint("CheckResult")
private void setWikidataEditCount() {
String userName = sessionManager.getUserName();
if (StringUtils.isBlank(userName)) {
return;
}
compositeDisposable.add(okHttpJsonApiClient
.getWikidataEdits(userName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(edits -> {
numberOfEdits = edits;
wikidataEditsText.setText(String.valueOf(edits));
}, e -> {
Timber.e("Error:" + e);
}));
}
/**
* Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the
* listener passed
* @param tooManyAchievements if this value is true it means that the number of achievements of the
* user are so high that it wrecks havoc with the Achievements calculator due to which request may time
* out. Well this is the Ultimate Achievement
*/
private void showSnackBarWithRetry(boolean tooManyAchievements) {
if (tooManyAchievements) {
progressBar.setVisibility(View.GONE);
ViewUtil.showDismissibleSnackBar(findViewById(android.R.id.content),
R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements());
} else {
progressBar.setVisibility(View.GONE);
ViewUtil.showDismissibleSnackBar(findViewById(android.R.id.content),
R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements());
}
}
/**
* Shows a generic error toast when error occurs while loading achievements or uploads
*/
private void onError() {
ViewUtil.showLongToast(this, getResources().getString(R.string.error_occurred));
progressBar.setVisibility(View.GONE);
}
/**
* used to the count of images uploaded by user
*/
private void setUploadCount(Achievements achievements) {
if (checkAccount()) {
compositeDisposable.add(okHttpJsonApiClient
.getUploadCount(Objects.requireNonNull(sessionManager.getCurrentAccount()).name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
uploadCount -> setAchievementsUploadCount(achievements, uploadCount),
t -> {
Timber.e(t, "Fetching upload count failed");
onError();
}
));
}
}
/**
* used to set achievements upload count and call hideProgressbar
* @param uploadCount
*/
private void setAchievementsUploadCount(Achievements achievements, int uploadCount) {
achievements.setImagesUploaded(uploadCount);
hideProgressBar(achievements);
}
/**
* used to the uploaded images progressbar
* @param uploadCount
*/
private void setUploadProgress(int uploadCount){
if (uploadCount==0){
setZeroAchievements();
}else {
imagesUploadedProgressbar.setProgress
(100*uploadCount/levelInfo.getMaxUploadCount());
imagesUploadedProgressbar.setProgressTextFormatPattern
(uploadCount +"/" + levelInfo.getMaxUploadCount() );
}
}
private void setZeroAchievements() {
AlertDialog.Builder builder=new AlertDialog.Builder(this)
.setMessage(getString(R.string.no_achievements_yet))
.setPositiveButton(getString(R.string.ok), (dialog, which) -> {
});
AlertDialog dialog = builder.create();
dialog.show();
imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
imageRevertsProgressbar.setVisibility(View.INVISIBLE);
imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
imageView.setVisibility(View.INVISIBLE);
imageByWikiText.setText(R.string.no_image);
imageRevertedText.setText(R.string.no_image_reverted);
imageUploadedText.setText(R.string.no_image_uploaded);
imageView.setVisibility(View.INVISIBLE);
}
/**
* used to set the non revert image percentage
* @param notRevertPercentage
*/
private void setImageRevertPercentage(int notRevertPercentage){
imageRevertsProgressbar.setProgress(notRevertPercentage);
String revertPercentage = Integer.toString(notRevertPercentage);
imageRevertsProgressbar.setProgressTextFormatPattern(revertPercentage + "%%");
imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%");
}
/**
* Used the inflate the fetched statistics of the images uploaded by user
* and assign badge and level
* @param achievements
*/
private void inflateAchievements(Achievements achievements) {
thanksReceived.setText(String.valueOf(achievements.getThanksReceived()));
imagesUsedByWikiProgressBar.setProgress
(100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages());
imagesUsedByWikiProgressBar.setProgressTextFormatPattern
(achievements.getUniqueUsedImages() + "/" + levelInfo.getMaxUniqueImages());
imagesFeatured.setText(String.valueOf(achievements.getFeaturedImages()));
String levelUpInfoString = getString(R.string.level).toUpperCase();
levelUpInfoString += " " + levelInfo.getLevelNumber();
levelNumber.setText(levelUpInfoString);
imageView.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge,
new ContextThemeWrapper(this, levelInfo.getLevelStyle()).getTheme()));
badgeText.setText(Integer.toString(levelInfo.getLevelNumber()));
}
/**
* Creates a way to change current activity to AchievementActivity
* @param context
*/
public static void startYourself(Context context) {
Intent intent = new Intent(context, AchievementsActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(intent);
}
/**
* to hide progressbar
*/
private void hideProgressBar(Achievements achievements) {
if (progressBar != null) {
levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(),
achievements.getUniqueUsedImages(),
achievements.getNotRevertPercentage());
inflateAchievements(achievements);
setUploadProgress(achievements.getImagesUploaded());
setImageRevertPercentage(achievements.getNotRevertPercentage());
progressBar.setVisibility(View.GONE);
item.setVisible(true);
layoutImageReverts.setVisibility(View.VISIBLE);
layoutImageUploaded.setVisibility(View.VISIBLE);
layoutImageUsedByWiki.setVisibility(View.VISIBLE);
layoutStatistics.setVisibility(View.VISIBLE);
imageView.setVisibility(View.VISIBLE);
levelNumber.setVisibility(View.VISIBLE);
}
}
/**
* used to hide the layouts while fetching results from api
*/
private void hideLayouts(){
layoutImageUsedByWiki.setVisibility(View.INVISIBLE);
layoutImageUploaded.setVisibility(View.INVISIBLE);
layoutImageReverts.setVisibility(View.INVISIBLE);
layoutStatistics.setVisibility(View.INVISIBLE);
imageView.setVisibility(View.INVISIBLE);
levelNumber.setVisibility(View.INVISIBLE);
}
/**
* It display the alertDialog with Image of screenshot
* @param screenshot
*/
public void showAlert(Bitmap screenshot){
AlertDialog.Builder alertadd = new AlertDialog.Builder(AchievementsActivity.this);
LayoutInflater factory = LayoutInflater.from(AchievementsActivity.this);
final View view = factory.inflate(R.layout.image_alert_layout, null);
ImageView screenShotImage = view.findViewById(R.id.alert_image);
screenShotImage.setImageBitmap(screenshot);
TextView shareMessage = view.findViewById(R.id.alert_text);
shareMessage.setText(R.string.achievements_share_message);
alertadd.setView(view);
alertadd.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> shareScreen(screenshot));
alertadd.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel());
alertadd.show();
}
@OnClick(R.id.images_upload_info)
public void showUploadInfo(){
launchAlert(getResources().getString(R.string.images_uploaded)
,getResources().getString(R.string.images_uploaded_explanation));
}
@OnClick(R.id.images_reverted_info)
public void showRevertedInfo(){
launchAlert(getResources().getString(R.string.image_reverts)
,getResources().getString(R.string.images_reverted_explanation));
}
@OnClick(R.id.images_used_by_wiki_info)
public void showUsedByWikiInfo(){
launchAlert(getResources().getString(R.string.images_used_by_wiki)
,getResources().getString(R.string.images_used_explanation));
}
@OnClick(R.id.images_nearby_info)
public void showImagesViaNearbyInfo(){
launchAlert(getResources().getString(R.string.statistics_wikidata_edits)
,getResources().getString(R.string.images_via_nearby_explanation));
}
@OnClick(R.id.images_featured_info)
public void showFeaturedImagesInfo(){
launchAlert(getResources().getString(R.string.statistics_featured)
,getResources().getString(R.string.images_featured_explanation));
}
@OnClick(R.id.thanks_received_info)
public void showThanksReceivedInfo(){
launchAlert(getResources().getString(R.string.statistics_thanks)
,getResources().getString(R.string.thanks_received_explanation));
}
/**
* takes title and message as input to display alerts
* @param title
* @param message
*/
private void launchAlert(String title, String message){
new AlertDialog.Builder(AchievementsActivity.this)
.setTitle(title)
.setMessage(message)
.setCancelable(true)
.setPositiveButton(android.R.string.ok, (dialog, id) -> dialog.cancel())
.create()
.show();
}
/**
* check to ensure that user is logged in
* @return
*/
private boolean checkAccount(){
Account currentAccount = sessionManager.getCurrentAccount();
if (currentAccount == null) {
Timber.d("Current account is null");
ViewUtil.showLongToast(this, getResources().getString(R.string.user_not_logged_in));
sessionManager.forceLogin(this);
return false;
}
return true;
}
}

View file

@ -0,0 +1,12 @@
package fr.free.nrw.commons.achievements
import com.google.gson.annotations.SerializedName
/**
* Represents Featured Images on WikiMedia Commons platform
* Used by Achievements and FeedbackResponse (objects) of the user
*/
class FeaturedImages(
@field:SerializedName("Quality_images") val qualityImages: Int,
@field:SerializedName("Featured_pictures_on_Wikimedia_Commons") val featuredPicturesOnWikimediaCommons: Int
)

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.achievements
/**
* Represent the Feedback Response of the user
*/
data class FeedbackResponse(val uniqueUsedImages: Int,
val articlesUsingImages: Int,
val deletedUploads: Int,
val featuredImages: FeaturedImages,
val thanksReceived: Int,
val user: String)

View file

@ -0,0 +1,35 @@
package fr.free.nrw.commons.achievements
import fr.free.nrw.commons.R
/**
* calculates the level of the user
*/
class LevelController {
var level: LevelInfo? = null
enum class LevelInfo(val levelNumber: Int,
val levelStyle: Int,
val maxUniqueImages: Int,
val maxUploadCount: Int,
val minNonRevertPercentage: Int) {
LEVEL_1(1, R.style.LevelOne, 5, 20, 85), LEVEL_2(2, R.style.LevelTwo, 10, 30, 86), LEVEL_3(3, R.style.LevelThree, 15, 40, 87), LEVEL_4(4, R.style.LevelFour, 20, 50, 88), LEVEL_5(5, R.style.LevelFive, 25, 60, 89), LEVEL_6(6, R.style.LevelOne, 30, 70, 90), LEVEL_7(7, R.style.LevelTwo, 40, 80, 90), LEVEL_8(8, R.style.LevelThree, 45, 90, 90), LEVEL_9(9, R.style.LevelFour, 50, 100, 90), LEVEL_10(10, R.style.LevelFive, 55, 110, 90), LEVEL_11(11, R.style.LevelOne, 60, 120, 90), LEVEL_12(12, R.style.LevelTwo, 65, 130, 90), LEVEL_13(13, R.style.LevelThree, 70, 140, 90), LEVEL_14(14, R.style.LevelFour, 75, 150, 90), LEVEL_15(15, R.style.LevelFive, 80, 160, 90), LEVEL_16(16, R.style.LevelOne, 160, 320, 91), LEVEL_17(17, R.style.LevelTwo, 320, 640, 92), LEVEL_18(18, R.style.LevelThree, 640, 1280, 93), LEVEL_19(19, R.style.LevelFour, 1280, 2560, 94), LEVEL_20(20, R.style.LevelFive, 2560, 5120, 95), LEVEL_21(21, R.style.LevelOne, 5120, 10240, 96), LEVEL_22(22, R.style.LevelTwo, 10240, 20480, 97), LEVEL_23(23, R.style.LevelThree, 20480, 40960, 98), LEVEL_24(24, R.style.LevelFour, 40960, 81920, 98), LEVEL_25(25, R.style.LevelFive, 81920, 163840, 98), LEVEL_26(26, R.style.LevelOne, 163840, 327680, 98), LEVEL_27(27, R.style.LevelTwo, 327680, 655360, 98);
companion object {
@JvmStatic
fun from(imagesUploaded: Int,
uniqueImagesUsed: Int,
nonRevertRate: Int): LevelInfo {
var level = LEVEL_15
for (levelInfo in values()) {
if (imagesUploaded < levelInfo.maxUploadCount || uniqueImagesUsed < levelInfo.maxUniqueImages || nonRevertRate < levelInfo.minNonRevertPercentage) {
level = levelInfo
return level
}
}
return level
}
}
}
}

View file

@ -1,18 +0,0 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.wikidata.mwapi.MwResponse
/**
* Response of the Thanks API.
* Context:
* The Commons Android app lets you thank other contributors who have uploaded a great picture.
* See https://www.mediawiki.org/wiki/Extension:Thanks
*/
class MwThankPostResponse : MwResponse() {
var result: Result? = null
inner class Result {
var success: Int? = null
var recipient: String? = null
}
}

View file

@ -0,0 +1,74 @@
package fr.free.nrw.commons.actions;
import org.wikipedia.csrf.CsrfTokenClient;
import org.wikipedia.dataclient.Service;
import io.reactivex.Observable;
/**
* This class acts as a Client to facilitate wiki page editing
* services to various dependency providing modules such as the Network module, the Review Controller ,etc
*
* The methods provided by this class will post to the Media wiki api
* documented at: https://commons.wikimedia.org/w/api.php?action=help&modules=edit
*/
public class PageEditClient {
private final CsrfTokenClient csrfTokenClient;
private final PageEditInterface pageEditInterface;
private final Service service;
public PageEditClient(CsrfTokenClient csrfTokenClient,
PageEditInterface pageEditInterface,
Service service) {
this.csrfTokenClient = csrfTokenClient;
this.pageEditInterface = pageEditInterface;
this.service = service;
}
/**
* This method is used when the content of the page is to be replaced by new content received
* @param pagetitle Title of the page to edit
* @param text Holds the page content
* @param summary Edit summary
*/
public Observable<Boolean> edit(String pageTitle, String text, String summary) {
try {
return pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
.map(editResponse -> editResponse.edit().editSucceeded());
} catch (Throwable throwable) {
return Observable.just(false);
}
}
/**
* This method is used when we need to append something to the end of wiki page content
* @param pagetitle Title of the page to edit
* @param appendText The received page content is added to beginning of the page
* @param summary Edit summary
*/
public Observable<Boolean> appendEdit(String pageTitle, String appendText, String summary) {
try {
return pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
.map(editResponse -> editResponse.edit().editSucceeded());
} catch (Throwable throwable) {
return Observable.just(false);
}
}
/**
* This method is used when we need to add something to the starting of the page
* @param pagetitle Title of the page to edit
* @param prependText The received page content is added to beginning of the page
* @param summary Edit summary
*/
public Observable<Boolean> prependEdit(String pageTitle, String prependText, String summary) {
try {
return pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
.map(editResponse -> editResponse.edit().editSucceeded());
} catch (Throwable throwable) {
return Observable.just(false);
}
}
}

View file

@ -1,201 +0,0 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import io.reactivex.Observable
import io.reactivex.Single
/**
* This class acts as a Client to facilitate wiki page editing
* services to various dependency providing modules such as the Network module, the Review Controller, etc.
*
* The methods provided by this class will post to the Media wiki api
* documented at: https://commons.wikimedia.org/w/api.php?action=help&modules=edit
*/
class PageEditClient(
private val csrfTokenClient: CsrfTokenClient,
private val pageEditInterface: PageEditInterface,
) {
/**
* Replace the content of a wiki page
* @param pageTitle Title of the page to edit
* @param text Holds the page content
* @param summary Edit summary
* @return whether the edit was successful
*/
fun edit(
pageTitle: String,
text: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
.map { editResponse ->
editResponse.edit()!!.editSucceeded()
}
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
/**
* Creates a new page with the given title, text, and summary.
*
* @param pageTitle The title of the page to be created.
* @param text The content of the page in wikitext format.
* @param summary The edit summary for the page creation.
* @return An observable that emits true if the page creation succeeded, false otherwise.
* @throws InvalidLoginTokenException If an invalid login token is encountered during the process.
*/
fun postCreate(
pageTitle: String,
text: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postCreate(
pageTitle,
summary,
text,
"text/x-wiki",
"wikitext",
true,
true,
csrfTokenClient.getTokenBlocking(),
).map { editResponse ->
editResponse.edit()!!.editSucceeded()
}
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
/**
* Append text to the end of a wiki page
* @param pageTitle Title of the page to edit
* @param appendText The received page content is added to the end of the page
* @param summary Edit summary
* @return whether the edit was successful
*/
fun appendEdit(
pageTitle: String,
appendText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
/**
* Prepend text to the beginning of a wiki page
* @param pageTitle Title of the page to edit
* @param prependText The received page content is added to the beginning of the page
* @param summary Edit summary
* @return whether the edit was successful
*/
fun prependEdit(
pageTitle: String,
prependText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()?.editSucceeded() ?: false }
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
/**
* Appends a new section to the wiki page
* @param pageTitle Title of the page to edit
* @param sectionTitle Title of the new section that needs to be created
* @param sectionText The page content that is to be added to the section
* @param summary Edit summary
* @return whether the edit was successful
*/
fun createNewSection(
pageTitle: String,
sectionTitle: String,
sectionText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(false)
}
}
/**
* Set new labels to Wikibase server of commons
* @param summary Edit summary
* @param title Title of the page to edit
* @param language Corresponding language of label
* @param value label
* @return 1 when the edit was successful
*/
fun setCaptions(
summary: String,
title: String,
language: String,
value: String,
): Observable<Int> =
try {
pageEditInterface
.postCaptions(
summary,
title,
language,
value,
csrfTokenClient.getTokenBlocking(),
).map { it.success }
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
throw throwable
} else {
Observable.just(0)
}
}
/**
* Get whole WikiText of required file
* @param title : Name of the file
* @return Observable<MwQueryResult>
*/
fun getCurrentWikiText(title: String): Single<String?> =
pageEditInterface.getWikiText(title).map {
it
.query()
?.pages()
?.get(0)
?.revisions()
?.get(0)
?.content()
}
}

View file

@ -0,0 +1,76 @@
package fr.free.nrw.commons.actions;
import androidx.annotation.NonNull;
import org.wikipedia.edit.Edit;
import io.reactivex.Observable;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.Headers;
import retrofit2.http.POST;
import static org.wikipedia.dataclient.Service.MW_API_PREFIX;
/**
* This interface facilitates wiki commons page editing services to the Networking module
* which provides all network related services used by the app.
*
* This interface posts a form encoded request to the wikimedia API
* with editing action as argument to edit a particular page
*/
public interface PageEditInterface {
/**
* This method posts such that the Content which the page
* has will be completely replaced by the value being passed to the
* "text" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param text Holds the page content
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
@NonNull
Observable<Edit> postEdit(@NonNull @Field("title") String title,
@NonNull @Field("summary") String summary,
@NonNull @Field("text") String text,
// NOTE: This csrf shold always be sent as the last field of form data
@NonNull @Field("token") String token);
/**
* This method posts such that the Content which the page
* has will be completely replaced by the value being passed to the
* "text" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param text The received page content is added to beginning of the page
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
@NonNull Observable<Edit> postAppendEdit(@NonNull @Field("title") String title,
@NonNull @Field("summary") String summary,
@NonNull @Field("appendtext") String text,
@NonNull @Field("token") String token);
/**
* This method posts such that the Content which the page
* has will be completely replaced by the value being passed to the
* "text" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param text The received page content is added to beginning of the page
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
@NonNull Observable<Edit> postPrependEdit(@NonNull @Field("title") String title,
@NonNull @Field("summary") String summary,
@NonNull @Field("prependtext") String text,
@NonNull @Field("token") String token);
}

View file

@ -1,141 +0,0 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
import fr.free.nrw.commons.wikidata.model.Entities
import fr.free.nrw.commons.wikidata.model.edit.Edit
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Observable
import io.reactivex.Single
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
/**
* This interface facilitates wiki commons page editing services to the Networking module
* which provides all network related services used by the app.
*
* This interface posts a form encoded request to the wikimedia API
* with editing action as argument to edit a particular page
*/
interface PageEditInterface {
/**
* This method posts such that the Content which the page
* has will be completely replaced by the value being passed to the
* "text" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param text Holds the page content
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
fun postEdit(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("text") text: String,
// NOTE: This csrf shold always be sent as the last field of form data
@Field("token") token: String,
): Observable<Edit>
/**
* This method creates or edits a page for nearby items.
*
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also used as the section title when section=new and sectiontitle is not set.
* @param text Text of the page.
* @param contentformat Format of the content (e.g., "text/x-wiki").
* @param contentmodel Model of the content (e.g., "wikitext").
* @param minor Whether the edit is a minor edit.
* @param recreate Whether to recreate the page if it does not exist.
* @param token A "csrf" token. This should always be sent as the last field of form data.
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
fun postCreate(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("text") text: String,
@Field("contentformat") contentformat: String,
@Field("contentmodel") contentmodel: String,
@Field("minor") minor: Boolean,
@Field("recreate") recreate: Boolean,
// NOTE: This csrf shold always be sent as the last field of form data
@Field("token") token: String,
): Observable<Edit>
/**
* This method posts such that the Content which the page
* has will be appended with the value being passed to the
* "appendText" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param appendText Text to add to the end of the page
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
fun postAppendEdit(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("appendtext") appendText: String,
@Field("token") token: String,
): Observable<Edit>
/**
* This method posts such that the Content which the page
* has will be prepended with the value being passed to the
* "prependText" field of the encoded form data
* @param title Title of the page to edit. Cannot be used together with pageid.
* @param summary Edit summary. Also section title when section=new and sectiontitle is not set
* @param prependText Text to add to the beginning of the page
* @param token A "csrf" token
*/
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit")
fun postPrependEdit(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("prependtext") prependText: String,
@Field("token") token: String,
): Observable<Edit>
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=edit&section=new")
fun postNewSection(
@Field("title") title: String,
@Field("summary") summary: String,
@Field("sectiontitle") sectionTitle: String,
@Field("text") sectionText: String,
@Field("token") token: String,
): Observable<Edit>
@FormUrlEncoded
@Headers("Cache-Control: no-cache")
@POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
fun postCaptions(
@Field("summary") summary: String,
@Field("title") title: String,
@Field("language") language: String,
@Field("value") value: String,
@Field("token") token: String,
): Observable<Entities>
/**
* Gets the wiki text for the provided file name.
*
* @param title The title (name) of the file to fetch wiki text for.
* @return A Single emitting the wiki query response.
*/
@GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=")
fun getWikiText(
@Query("titles") title: String,
): Single<MwQueryResponse?>
}

View file

@ -0,0 +1,48 @@
package fr.free.nrw.commons.actions;
import org.wikipedia.csrf.CsrfTokenClient;
import org.wikipedia.dataclient.Service;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.CommonsApplication;
import io.reactivex.Observable;
/**
* Facilitates the Wkikimedia Thanks api extention, as described in the
* api documentation: "The Thanks extension includes an API for sending thanks"
*
* In simple terms this class is used by a user to thank someone for adding
* contribution to the commons platform
*/
@Singleton
public class ThanksClient {
private final CsrfTokenClient csrfTokenClient;
private final Service service;
@Inject
public ThanksClient(@Named("commons-csrf") CsrfTokenClient csrfTokenClient,
@Named("commons-service") Service service) {
this.csrfTokenClient = csrfTokenClient;
this.service = service;
}
/**
* Handles the Thanking logic
* @param revesionID The revision ID you would like to thank someone for
* @return if thanks was successfully sent to intended recepient, returned as a boolean observable
*/
public Observable<Boolean> thank(long revisionId) {
try {
return service.thank(String.valueOf(revisionId), null,
csrfTokenClient.getTokenBlocking(),
CommonsApplication.getInstance().getUserAgent())
.map(mwQueryResponse -> mwQueryResponse.getSuccessVal() == 1);
} catch (Throwable throwable) {
return Observable.just(false);
}
}
}

View file

@ -1,46 +0,0 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF
import io.reactivex.Observable
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* Client for the Wkikimedia Thanks API extension
* Thanks are used by a user to show gratitude to another user for their contributions
*/
@Singleton
class ThanksClient
@Inject
constructor(
@param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient,
private val service: ThanksInterface,
) {
/**
* Thanks a user for a particular revision
* @param revisionId The revision ID the user would like to thank someone for
* @return if thanks was successfully sent to intended recipient
*/
fun thank(revisionId: Long): Observable<Boolean> =
try {
service
.thank(
revisionId.toString(), // Rev
null, // Log
csrfTokenClient.getTokenBlocking(), // Token
CommonsApplication.instance.userAgent, // Source
).map { mwThankPostResponse ->
mwThankPostResponse.result?.success == 1
}
} catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) {
Observable.error(throwable)
} else {
Observable.just(false)
}
}
}

View file

@ -1,24 +0,0 @@
package fr.free.nrw.commons.actions
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
import io.reactivex.Observable
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
/**
* Thanks API.
* Context:
* The Commons Android app lets you thank another contributor who has uploaded a great picture.
* See https://www.mediawiki.org/wiki/Extension:Thanks
*/
interface ThanksInterface {
@FormUrlEncoded
@POST(MW_API_PREFIX + "action=thank")
fun thank(
@Field("rev") rev: String?,
@Field("log") log: String?,
@Field("token") token: String,
@Field("source") source: String?,
): Observable<MwThankPostResponse?>
}

View file

@ -1,218 +0,0 @@
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("%s%s",
"Console: ${message.message()} -- From line ",
"${message.lineNumber()} of ${message.sourceId()}")
return true
}
}
loadUrl(url)
}
},
update = {
webView.value = it
}
)
}
/**
* Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`.
*
* @param url The URL for which cookies need to be set.
*/
private fun setCookies(url: String) {
CookieManager.getInstance().let {
val cookies = cookieJar.loadForRequest(url.toHttpUrl())
for (cookie in cookies) {
it.setCookie(url, cookie.toString())
}
}
}
companion object {
private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"
/**
* Launch the WebViewActivity with the specified URL and success URL.
* @param context The context from which the activity is launched.
* @param url The initial URL to load in the WebView.
* @param successUrl The URL that triggers the WebView to close when matched.
*/
fun showWebView(
context: Context,
url: String,
successUrl: String
) {
val intent = Intent(
context,
SingleWebViewActivity::class.java
).apply {
putExtra(VANISH_ACCOUNT_URL, url)
putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl)
}
context.startActivity(intent)
}
}
}

View file

@ -0,0 +1,44 @@
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

@ -1,24 +0,0 @@
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

@ -0,0 +1,459 @@
package fr.free.nrw.commons.auth;
import android.accounts.AccountAuthenticatorActivity;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.NavUtils;
import androidx.core.content.ContextCompat;
import com.google.android.material.textfield.TextInputLayout;
import org.wikipedia.AppAdapter;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.login.LoginClient;
import org.wikipedia.login.LoginClient.LoginCallback;
import org.wikipedia.login.LoginResult;
import javax.inject.Inject;
import javax.inject.Named;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.OnEditorAction;
import butterknife.OnFocusChange;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.explore.categories.ExploreActivity;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.disposables.CompositeDisposable;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import timber.log.Timber;
import static android.view.KeyEvent.KEYCODE_ENTER;
import static android.view.View.VISIBLE;
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_WIKI_SITE;
public class LoginActivity extends AccountAuthenticatorActivity {
@Inject
SessionManager sessionManager;
@Inject
@Named(NAMED_COMMONS_WIKI_SITE)
WikiSite commonsWikiSite;
@Inject
@Named("default_preferences")
JsonKvStore applicationKvStore;
@Inject
LoginClient loginClient;
@Inject
SystemThemeUtils systemThemeUtils;
@BindView(R.id.login_button)
Button loginButton;
@BindView(R.id.login_username)
EditText usernameEdit;
@BindView(R.id.login_password)
EditText passwordEdit;
@BindView(R.id.login_two_factor)
EditText twoFactorEdit;
@BindView(R.id.error_message_container)
ViewGroup errorMessageContainer;
@BindView(R.id.error_message)
TextView errorMessage;
@BindView(R.id.login_credentials)
TextView loginCredentials;
@BindView(R.id.two_factor_container)
TextInputLayout twoFactorContainer;
ProgressDialog progressDialog;
private AppCompatDelegate delegate;
private LoginTextWatcher textWatcher = new LoginTextWatcher();
private CompositeDisposable compositeDisposable = new CompositeDisposable();
private Call<MwQueryResponse> loginToken;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ApplicationlessInjection
.getInstance(this.getApplicationContext())
.getCommonsApplicationComponent()
.inject(this);
boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode();
setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
getDelegate().installViewFactory();
getDelegate().onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
ButterKnife.bind(this);
usernameEdit.addTextChangedListener(textWatcher);
passwordEdit.addTextChangedListener(textWatcher);
twoFactorEdit.addTextChangedListener(textWatcher);
if (ConfigUtils.isBetaFlavour()) {
loginCredentials.setText(getString(R.string.login_credential));
} else {
loginCredentials.setVisibility(View.GONE);
}
}
@OnFocusChange(R.id.login_password)
void onPasswordFocusChanged(View view, boolean hasFocus) {
if (!hasFocus) {
ViewUtil.hideKeyboard(view);
}
}
@OnEditorAction(R.id.login_password)
boolean onEditorAction(int actionId, KeyEvent keyEvent) {
if (loginButton.isEnabled()) {
if (actionId == IME_ACTION_DONE) {
performLogin();
return true;
} else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
performLogin();
return true;
}
}
return false;
}
@OnClick(R.id.skip_login)
void skipLogin() {
new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
.setMessage(R.string.skip_login_message)
.setCancelable(false)
.setPositiveButton(R.string.yes, (dialog, which) -> {
dialog.cancel();
performSkipLogin();
})
.setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel())
.show();
}
@OnClick(R.id.forgot_password)
void forgotPassword() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
}
@OnClick(R.id.about_privacy_policy)
void onPrivacyPolicyClicked() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
}
@OnClick(R.id.sign_up_button)
void signUp() {
Intent intent = new Intent(this, SignupActivity.class);
startActivity(intent);
}
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
getDelegate().onPostCreate(savedInstanceState);
}
@Override
protected void onResume() {
super.onResume();
if (applicationKvStore.getBoolean("firstrun", true)) {
WelcomeActivity.startYourself(this);
}
if (sessionManager.getCurrentAccount() != null
&& sessionManager.isUserLoggedIn()) {
applicationKvStore.putBoolean("login_skipped", false);
startMainActivity();
}
if (applicationKvStore.getBoolean("login_skipped", false)) {
performSkipLogin();
}
}
@Override
protected void onDestroy() {
compositeDisposable.clear();
try {
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
if (progressDialog != null && progressDialog.isShowing()) {
progressDialog.dismiss();
}
} catch (Exception e) {
e.printStackTrace();
}
usernameEdit.removeTextChangedListener(textWatcher);
passwordEdit.removeTextChangedListener(textWatcher);
twoFactorEdit.removeTextChangedListener(textWatcher);
delegate.onDestroy();
if(null!=loginClient) {
loginClient.cancel();
}
super.onDestroy();
}
@OnClick(R.id.login_button)
public void performLogin() {
Timber.d("Login to start!");
final String username = usernameEdit.getText().toString();
final String rawUsername = usernameEdit.getText().toString().trim();
final String password = passwordEdit.getText().toString();
String twoFactorCode = twoFactorEdit.getText().toString();
showLoggingProgressBar();
doLogin(username, password, twoFactorCode);
}
private void doLogin(String username, String password, String twoFactorCode) {
progressDialog.show();
loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
loginToken.enqueue(
new Callback<MwQueryResponse>() {
@Override
public void onResponse(Call<MwQueryResponse> call,
Response<MwQueryResponse> response) {
loginClient.login(commonsWikiSite, username, password, null, twoFactorCode,
response.body().query().loginToken(), new LoginCallback() {
@Override
public void success(@NonNull LoginResult result) {
Timber.d("Login Success");
onLoginSuccess(result);
}
@Override
public void twoFactorPrompt(@NonNull Throwable caught,
@Nullable String token) {
Timber.d("Requesting 2FA prompt");
hideProgress();
askUserForTwoFactorAuth();
}
@Override
public void passwordResetPrompt(@Nullable String token) {
Timber.d("Showing password reset prompt");
hideProgress();
showPasswordResetPrompt();
}
@Override
public void error(@NonNull Throwable caught) {
Timber.e(caught);
hideProgress();
showMessageAndCancelDialog(caught.getLocalizedMessage());
}
});
}
@Override
public void onFailure(Call<MwQueryResponse> call, Throwable t) {
Timber.e(t);
showMessageAndCancelDialog(t.getLocalizedMessage());
}
});
}
private void hideProgress() {
progressDialog.dismiss();
}
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);
ExploreActivity.startYourself(this);
finish();
}
private void showLoggingProgressBar() {
progressDialog = new ProgressDialog(this);
progressDialog.setIndeterminate(true);
progressDialog.setTitle(getString(R.string.logging_in_title));
progressDialog.setMessage(getString(R.string.logging_in_message));
progressDialog.setCanceledOnTouchOutside(false);
progressDialog.show();
}
private void onLoginSuccess(LoginResult loginResult) {
if (!progressDialog.isShowing()) {
// no longer attached to activity!
return;
}
sessionManager.setUserLoggedIn(true);
AppAdapter.get().updateAccount(loginResult);
progressDialog.dismiss();
showSuccessAndDismissDialog();
startMainActivity();
}
@Override
protected void onStart() {
super.onStart();
delegate.onStart();
}
@Override
protected void onStop() {
super.onStop();
delegate.onStop();
}
@Override
protected void onPostResume() {
super.onPostResume();
getDelegate().onPostResume();
}
@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
getDelegate().setContentView(view, params);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
NavUtils.navigateUpFromSameTask(this);
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
@NonNull
public MenuInflater getMenuInflater() {
return getDelegate().getMenuInflater();
}
public void askUserForTwoFactorAuth() {
progressDialog.dismiss();
twoFactorContainer.setVisibility(VISIBLE);
twoFactorEdit.setVisibility(VISIBLE);
twoFactorEdit.requestFocus();
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
}
public void showMessageAndCancelDialog(@StringRes int resId) {
showMessage(resId, R.color.secondaryDarkColor);
if (progressDialog != null) {
progressDialog.cancel();
}
}
public void showMessageAndCancelDialog(String error) {
showMessage(error, R.color.secondaryDarkColor);
if (progressDialog != null) {
progressDialog.cancel();
}
}
public void showSuccessAndDismissDialog() {
showMessage(R.string.login_success, R.color.primaryDarkColor);
progressDialog.dismiss();
}
public void startMainActivity() {
NavigationBaseActivity.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
finish();
}
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
errorMessage.setText(getString(resId));
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
errorMessageContainer.setVisibility(VISIBLE);
}
private void showMessage(String message, @ColorRes int colorResId) {
errorMessage.setText(message);
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
errorMessageContainer.setVisibility(VISIBLE);
}
private AppCompatDelegate getDelegate() {
if (delegate == null) {
delegate = AppCompatDelegate.create(this, null);
}
return delegate;
}
private class LoginTextWatcher implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable editable) {
boolean enabled = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
&& (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
loginButton.setEnabled(enabled);
}
}
public static void startYourself(Context context) {
Intent intent = new Intent(context, LoginActivity.class);
context.startActivity(intent);
}
}

View file

@ -1,489 +0,0 @@
package fr.free.nrw.commons.auth
import android.accounts.AccountAuthenticatorActivity
import android.app.ProgressDialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NavUtils
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.login.LoginCallback
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginResult
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.databinding.ActivityLoginBinding
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
import fr.free.nrw.commons.utils.AbstractTextWatcher
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.utils.SystemThemeUtils
import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
import fr.free.nrw.commons.utils.handleKeyboardInsets
import fr.free.nrw.commons.utils.handleWebUrl
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
class LoginActivity : AccountAuthenticatorActivity() {
@Inject
lateinit var sessionManager: SessionManager
@Inject
@field:Named("default_preferences")
lateinit var applicationKvStore: JsonKvStore
@Inject
lateinit var loginClient: LoginClient
@Inject
lateinit var systemThemeUtils: SystemThemeUtils
private var binding: ActivityLoginBinding? = null
private var progressDialog: ProgressDialog? = null
private val textWatcher = AbstractTextWatcher(::onTextChanged)
private val compositeDisposable = CompositeDisposable()
private val delegate: AppCompatDelegate by lazy {
AppCompatDelegate.create(this, null)
}
private var lastLoginResult: LoginResult? = null
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationlessInjection
.getInstance(this.applicationContext)
.commonsApplicationComponent
.inject(this)
val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
delegate.installViewFactory()
delegate.onCreate(savedInstanceState)
WindowCompat.getInsetsController(window, window.decorView)
.isAppearanceLightStatusBars = !isDarkTheme
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater)
applyEdgeToEdgeAllInsets(binding!!.root)
binding!!.root.handleKeyboardInsets()
with(binding!!) {
setContentView(root)
loginUsername.addTextChangedListener(textWatcher)
loginPassword.addTextChangedListener(textWatcher)
loginTwoFactor.addTextChangedListener(textWatcher)
skipLogin.setOnClickListener { skipLogin() }
forgotPassword.setOnClickListener { forgotPassword() }
aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
signUpButton.setOnClickListener { signUp() }
loginButton.setOnClickListener { performLogin() }
loginPassword.setOnEditorActionListener { textView, actionId, keyEvent ->
if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
if (actionId == EditorInfo.IME_ACTION_NEXT && lastLoginResult != null) {
askUserForTwoFactorAuthWithKeyboard()
true
} else {
performLogin()
true
}
} else {
false
}
}
loginPassword.onFocusChangeListener =
View.OnFocusChangeListener(::onPasswordFocusChanged)
if (isBetaFlavour) {
loginCredentials.text = getString(R.string.login_credential)
} else {
loginCredentials.visibility = View.GONE
}
intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
showMessage(it, R.color.secondaryDarkColor)
}
intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
loginUsername.setText(it)
}
}
}
@VisibleForTesting
fun askUserForTwoFactorAuthWithKeyboard() {
if (binding == null) {
Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuthWithKeyboard")
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding!!.root)
}
progressDialog!!.dismiss()
if (binding != null) {
with(binding!!) {
twoFactorContainer.visibility = View.VISIBLE
twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
loginTwoFactor.visibility = View.VISIBLE
loginTwoFactor.requestFocus()
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(loginTwoFactor, InputMethodManager.SHOW_IMPLICIT)
loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE ||
(event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
performLogin()
true
} else {
false
}
}
}
} else {
Timber.e("Binding is null in askUserForTwoFactorAuthWithKeyboard after reinitialization attempt")
}
showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
delegate.onPostCreate(savedInstanceState)
}
override fun onResume() {
super.onResume()
if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
applicationKvStore.putBoolean("login_skipped", false)
startMainActivity()
}
if (applicationKvStore.getBoolean("login_skipped", false)) {
performSkipLogin()
}
}
override fun onDestroy() {
compositeDisposable.clear()
try {
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
if (progressDialog?.isShowing == true) {
progressDialog!!.dismiss()
}
} catch (e: Exception) {
e.printStackTrace()
}
with(binding!!) {
loginUsername.removeTextChangedListener(textWatcher)
loginPassword.removeTextChangedListener(textWatcher)
loginTwoFactor.removeTextChangedListener(textWatcher)
}
delegate.onDestroy()
loginClient.cancel()
binding = null
super.onDestroy()
}
override fun onStart() {
super.onStart()
delegate.onStart()
}
override fun onStop() {
super.onStop()
delegate.onStop()
}
override fun onPostResume() {
super.onPostResume()
delegate.onPostResume()
}
override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
delegate.setContentView(view, params)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
NavUtils.navigateUpFromSameTask(this)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onSaveInstanceState(outState: Bundle) {
// if progressDialog is visible during the configuration change then store state as true else false so that
// we maintain visibility of progressDialog after configuration change
if (progressDialog != null && progressDialog!!.isShowing) {
outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
} else {
outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
}
outState.putString(
SAVE_ERROR_MESSAGE,
binding!!.errorMessage.text.toString()
) //Save the errorMessage
outState.putString(
SAVE_USERNAME,
binding!!.loginUsername.text.toString()
) // Save the username
outState.putString(
SAVE_PASSWORD,
binding!!.loginPassword.text.toString()
) // Save the password
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
performLogin()
}
val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
if (sessionManager.isUserLoggedIn) {
showMessage(R.string.login_success, R.color.primaryDarkColor)
} else {
showMessage(errorMessage, R.color.secondaryDarkColor)
}
}
/**
* Hides the keyboard if the user's focus is not on the password (hasFocus is false).
* @param view The keyboard
* @param hasFocus Set to true if the keyboard has focus
*/
private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
if (!hasFocus) {
hideKeyboard(view)
}
}
private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
performLogin()
true
} else false
private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
private fun skipLogin() {
AlertDialog.Builder(this)
.setTitle(R.string.skip_login_title)
.setMessage(R.string.skip_login_message)
.setCancelable(false)
.setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
dialog.cancel()
performSkipLogin()
}
.setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
dialog.cancel()
}
.show()
}
private fun forgotPassword() =
handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
private fun onPrivacyPolicyClicked() =
handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
private fun signUp() =
startActivity(Intent(this, SignupActivity::class.java))
@VisibleForTesting
fun performLogin() {
Timber.d("Login to start!")
val username = binding!!.loginUsername.text.toString()
val password = binding!!.loginPassword.text.toString()
val twoFactorCode = binding!!.loginTwoFactor.text.toString()
showLoggingProgressBar()
loginClient.doLogin(username,
password,
lastLoginResult,
twoFactorCode,
Locale.getDefault().language,
object : LoginCallback {
override fun success(loginResult: LoginResult) = runOnUiThread {
Timber.d("Login Success")
progressDialog!!.dismiss()
onLoginSuccess(loginResult)
}
override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
Timber.d("Requesting 2FA prompt")
progressDialog!!.dismiss()
lastLoginResult = loginResult
askUserForTwoFactorAuthWithKeyboard()
}
override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread {
Timber.d("Requesting email auth prompt")
progressDialog!!.dismiss()
lastLoginResult = loginResult
askUserForTwoFactorAuthWithKeyboard()
}
override fun passwordResetPrompt(token: String?) = runOnUiThread {
Timber.d("Showing password reset prompt")
progressDialog!!.dismiss()
showPasswordResetPrompt()
}
override fun error(caught: Throwable) = runOnUiThread {
Timber.e(caught)
progressDialog!!.dismiss()
showMessageAndCancelDialog(caught.localizedMessage ?: "")
}
}
)
}
private fun showPasswordResetPrompt() =
showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
/**
* This function is called when user skips the login.
* It redirects the user to Explore Activity.
*/
private fun performSkipLogin() {
applicationKvStore.putBoolean("login_skipped", true)
MainActivity.startYourself(this)
finish()
}
private fun showLoggingProgressBar() {
progressDialog = ProgressDialog(this).apply {
isIndeterminate = true
setTitle(getString(R.string.logging_in_title))
setMessage(getString(R.string.logging_in_message))
setCancelable(false)
}
progressDialog!!.show()
}
private fun onLoginSuccess(loginResult: LoginResult) {
compositeDisposable.clear()
sessionManager.setUserLoggedIn(true)
sessionManager.updateAccount(loginResult)
progressDialog!!.dismiss()
showSuccessAndDismissDialog()
startMainActivity()
}
override fun getMenuInflater(): MenuInflater =
delegate.menuInflater
@VisibleForTesting
fun askUserForTwoFactorAuth() {
if (binding == null) {
Timber.w("Binding is null, reinitializing in askUserForTwoFactorAuth")
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding!!.root)
}
progressDialog!!.dismiss()
if (binding != null) {
with(binding!!) {
twoFactorContainer.visibility = View.VISIBLE
twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code)
loginTwoFactor.visibility = View.VISIBLE
loginTwoFactor.requestFocus()
loginTwoFactor.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE ||
(event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
performLogin()
true
} else {
false
}
}
}
} else {
Timber.e("Binding is null in askUserForTwoFactorAuth after reinitialization attempt")
}
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed))
}
@VisibleForTesting
fun showMessageAndCancelDialog(@StringRes resId: Int) {
showMessage(resId, R.color.secondaryDarkColor)
progressDialog?.cancel()
}
@VisibleForTesting
fun showMessageAndCancelDialog(error: String) {
showMessage(error, R.color.secondaryDarkColor)
progressDialog?.cancel()
}
@VisibleForTesting
fun showSuccessAndDismissDialog() {
showMessage(R.string.login_success, R.color.primaryDarkColor)
progressDialog!!.dismiss()
}
@VisibleForTesting
fun startMainActivity() {
startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
finish()
}
private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
errorMessage.text = getString(resId)
errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
errorMessageContainer.visibility = View.VISIBLE
}
private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
errorMessage.text = message
errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
errorMessageContainer.visibility = View.VISIBLE
}
private fun onTextChanged(text: String) {
val enabled =
binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
(BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
binding!!.loginButton.isEnabled = enabled
}
companion object {
fun startYourself(context: Context) =
context.startActivity(Intent(context, LoginActivity::class.java))
const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
const val SAVE_ERROR_MESSAGE: String = "errorMessage"
const val SAVE_USERNAME: String = "username"
const val SAVE_PASSWORD: String = "password"
}
}

View file

@ -0,0 +1,36 @@
package fr.free.nrw.commons.auth;
import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.mwapi.MwPostResponse;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import io.reactivex.Observable;
/**
* Handler for logout
*/
@Singleton
public class LogoutClient {
private final Service service;
@Inject
public LogoutClient(@Named("commons-service") Service service) {
this.service = service;
}
/**
* Fetches the CSRF token and uses that to post the logout api call
* @return
*/
public Observable<MwPostResponse> postLogout() {
return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
}
}

View file

@ -0,0 +1,151 @@
package fr.free.nrw.commons.auth;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.login.LoginResult;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Completable;
import io.reactivex.Observable;
/**
* Manage the current logged in user session.
*/
@Singleton
public class SessionManager {
private final Context context;
private Account currentAccount; // Unlike a savings account... ;-)
private JsonKvStore defaultKvStore;
private static final String KEY_RAWUSERNAME = "rawusername";
@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
private String getRawUserName() {
Account account = getCurrentAccount();
return account == null ? null : accountManager().getUserData(account, KEY_RAWUSERNAME);
}
public String getAuthorName(){
return getRawUserName() == null ? getUserName() : getRawUserName();
}
@Nullable
public String getPassword() {
Account account = getCurrentAccount();
return account == null ? null : accountManager().getPassword(account);
}
private AccountManager accountManager() {
return AccountManager.get(context);
}
public boolean isUserLoggedIn() {
return defaultKvStore.getBoolean("isUserLoggedIn", false);
}
void setUserLoggedIn(boolean isLoggedIn) {
defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
}
public void forceLogin(Context context) {
if (context != null) {
LoginActivity.startYourself(context);
}
}
/**
* 1. Clears existing accounts from account manager
* 2. Calls MediaWikiApi's logout function to clear cookies
* @return
*/
public Completable logout() {
AccountManager accountManager = AccountManager.get(context);
Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
return Completable.fromObservable(Observable.fromArray(allAccounts)
.map(a -> accountManager.removeAccount(a, null, null).getResult()))
.doOnComplete(() -> {
currentAccount = null;
});
}
}

View file

@ -1,95 +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 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

@ -0,0 +1,64 @@
package fr.free.nrw.commons.auth;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.theme.BaseActivity;
import timber.log.Timber;
public class SignupActivity extends BaseActivity {
private WebView webView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Timber.d("Signup Activity started");
webView = new WebView(this);
setContentView(webView);
webView.setWebViewClient(new MyWebViewClient());
WebSettings webSettings = webView.getSettings();
/*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
trust Wikimedia's site... right?*/
webSettings.setJavaScriptEnabled(true);
webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
}
private class MyWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
//Signup success, so clear cookies, notify user, and load LoginActivity again
Timber.d("Overriding URL %s", url);
Toast toast = Toast.makeText(SignupActivity.this,
R.string.account_created, Toast.LENGTH_LONG);
toast.show();
// terminate on task completion.
finish();
return true;
} else {
//If user clicks any other links in the webview
Timber.d("Not overriding URL, URL is: %s", url);
return false;
}
}
}
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}

View file

@ -1,77 +0,0 @@
package fr.free.nrw.commons.auth
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.R
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.applyEdgeToEdgeAllInsets
import timber.log.Timber
class SignupActivity : BaseActivity() {
private var webView: WebView? = null
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Signup Activity started")
webView = WebView(this)
applyEdgeToEdgeAllInsets(webView!!)
with(webView!!) {
setContentView(this)
webViewClient = MyWebViewClient()
// Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
// trust Wikimedia's site... right?
settings.javaScriptEnabled = true
loadUrl(BuildConfig.SIGNUP_LANDING_URL)
}
}
override fun onBackPressed() {
if (webView!!.canGoBack()) {
webView!!.goBack()
} else {
super.onBackPressed()
}
}
/**
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
* https://issuetracker.google.com/issues/141132133
* App tries to put light/dark theme to webview and crashes in the process
* This code tries to prevent applying the theme when sdk is between api 21 to 25
*/
override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
if (Build.VERSION.SDK_INT <= 25 &&
(resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
) return
super.applyOverrideConfiguration(overrideConfiguration)
}
private inner class MyWebViewClient : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
//Signup success, so clear cookies, notify user, and load LoginActivity again
Timber.d("Overriding URL %s", url)
Toast.makeText(
this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
).show()
// terminate on task completion.
finish()
true
} else {
//If user clicks any other links in the webview
Timber.d("Not overriding URL, URL is: %s", url)
false
}
}
}

View file

@ -0,0 +1,141 @@
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

@ -1,108 +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.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

@ -0,0 +1,31 @@
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

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

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