mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-30 22:34:02 +01:00 
			
		
		
		
	Compare commits
	
		
			8 commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | e27f5d4458 | ||
|   | 50b3d2c5fb | ||
|   | 05895c5d9e | ||
|   | 9654a8ea34 | ||
|   | f4414b1c31 | ||
|   | 4edabede88 | ||
|   | 9880d7ea7e | ||
|   | 8b75c41143 | 
					 1671 changed files with 93494 additions and 96634 deletions
				
			
		
							
								
								
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug-report.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug-report.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -1,7 +1,7 @@ | |||
| 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 | ||||
| labels: ["bug"] | ||||
| body: | ||||
|   - type: markdown | ||||
|     attributes: | ||||
|  | @ -70,7 +70,7 @@ body: | |||
|       required: false | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: Screenshots | ||||
|       label: Screen-shots | ||||
|       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 | ||||
|  |  | |||
							
								
								
									
										28
									
								
								.github/workflows/android.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										28
									
								
								.github/workflows/android.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -1,6 +1,6 @@ | |||
| name: Android CI | ||||
| 
 | ||||
| on: [push, pull_request, workflow_dispatch] | ||||
| on: [push, pull_request] | ||||
| 
 | ||||
| concurrency: | ||||
|   group: build-${{ github.event.pull_request.number || github.ref }} | ||||
|  | @ -12,17 +12,17 @@ jobs: | |||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v2.4.0 | ||||
| 
 | ||||
|       - name: Set up JDK | ||||
|         uses: actions/setup-java@v4 | ||||
|         uses: actions/setup-java@v2.5.0 | ||||
|         with: | ||||
|           distribution: 'temurin' | ||||
|           java-version: '17' | ||||
|           distribution: "temurin" | ||||
|           java-version: 11 | ||||
| 
 | ||||
|       - name: Cache packages | ||||
|         id: cache-packages | ||||
|         uses: actions/cache@v4 | ||||
|         uses: actions/cache@v2.1.7 | ||||
|         with: | ||||
|           path: | | ||||
|             ~/.gradle/caches | ||||
|  | @ -37,7 +37,7 @@ jobs: | |||
| 
 | ||||
|       - name: AVD cache | ||||
|         if: github.event_name != 'pull_request' | ||||
|         uses: actions/cache@v4 | ||||
|         uses: actions/cache@v2 | ||||
|         id: avd-cache | ||||
|         with: | ||||
|           path: | | ||||
|  | @ -89,7 +89,7 @@ jobs: | |||
|         run: bash ./gradlew assembleBetaDebug --stacktrace | ||||
| 
 | ||||
|       - name: Upload betaDebug APK | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         uses: actions/upload-artifact@v2.3.1 | ||||
|         with: | ||||
|           name: betaDebugAPK | ||||
|           path: app/build/outputs/apk/beta/debug/app-*.apk | ||||
|  | @ -98,17 +98,7 @@ jobs: | |||
|         run: bash ./gradlew assembleProdDebug --stacktrace | ||||
| 
 | ||||
|       - name: Upload prodDebug APK | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         uses: actions/upload-artifact@v2.3.1 | ||||
|         with: | ||||
|           name: prodDebugAPK | ||||
|           path: app/build/outputs/apk/prod/debug/app-*.apk | ||||
| 
 | ||||
|       - name: Create and PR number artifact | ||||
|         run: | | ||||
|           echo "{\"pr_number\": ${{ github.event.pull_request.number || 'null' }}}" > pr_number.json | ||||
|            | ||||
|       - name: Upload PR number artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: pr_number | ||||
|           path: ./pr_number.json | ||||
|  |  | |||
							
								
								
									
										41
									
								
								.github/workflows/build-beta.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										41
									
								
								.github/workflows/build-beta.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -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 | ||||
							
								
								
									
										96
									
								
								.github/workflows/comment_artifacts_on_PR.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										96
									
								
								.github/workflows/comment_artifacts_on_PR.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -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 | ||||
|             }); | ||||
							
								
								
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -43,8 +43,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/.* | ||||
|  |  | |||
							
								
								
									
										18
									
								
								.idea/codeStyles/Project.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								.idea/codeStyles/Project.xml
									
										
									
										generated
									
									
									
								
							|  | @ -16,7 +16,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 +39,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" /> | ||||
|  | @ -316,7 +318,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> | ||||
|  |  | |||
							
								
								
									
										52
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										52
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,36 +1,16 @@ | |||
| <component name="InspectionProjectProfileManager"> | ||||
|   <profile version="1.0"> | ||||
|     <option name="myName" value="Project Default" /> | ||||
|     <inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|     <inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|     <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="DefaultNotLastCaseInSwitch" enabled="true" level="WARNING" 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="FieldMayBeFinal" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|     <inspection_tool class="LocalCanBeFinal" enabled="true" level="WARNING" enabled_by_default="true"> | ||||
|       <option name="REPORT_VARIABLES" value="true" /> | ||||
|       <option name="REPORT_PARAMETERS" value="true" /> | ||||
|  | @ -44,33 +24,14 @@ | |||
|     <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="ProtectedMemberInFinalClass" 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="RedundantMethodOverride" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|     <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"> | ||||
|  | @ -86,5 +47,6 @@ | |||
|     <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" /> | ||||
|     <inspection_tool class="UnnecessaryToStringCall" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||
|   </profile> | ||||
| </component> | ||||
							
								
								
									
										5
									
								
								.mailmap
									
										
									
									
									
								
							
							
						
						
									
										5
									
								
								.mailmap
									
										
									
									
									
								
							|  | @ -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> | ||||
							
								
								
									
										329
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										329
									
								
								CHANGELOG.md
									
										
									
									
									
								
							|  | @ -1,334 +1,5 @@ | |||
| # Wikimedia Commons for Android | ||||
| 
 | ||||
| ## v6.0.2 | ||||
| 
 | ||||
| ### What's changed | ||||
| * Addressed a bug that prevented the keyboard from appearing in various text fields, such as on the upload wizard | ||||
| * Links in the "File usages" list are now clickable and will take you to the correct page. | ||||
| * Titles for file usages are now clearer and easier to understand | ||||
| * Bug fixes and stability improvements | ||||
| 
 | ||||
| ## v6.0.1 | ||||
| 
 | ||||
| ### What's changed | ||||
| * The app now supports Android 15 with an improved user interface | ||||
| * Enhanced Nearby with robust and more reliable labels | ||||
| * Bug fixes and stability improvements | ||||
| 
 | ||||
| ## v5.6.1 | ||||
| 
 | ||||
| ### What's changed | ||||
| * The app no longer uploads images to Wikidata if one exists already for a given item | ||||
| * File usage displays correctly now | ||||
| * No more infinite circular progress bar on nominating an image for deletion | ||||
| * Enhanced location updates while using GPS | ||||
| * Author/uploader names are now available in Media Details for Commons licensing compliance | ||||
| * Improved usage of popups in Nearby | ||||
| * Bug fixes and stability improvements  | ||||
| 
 | ||||
| ## v5.5.0 | ||||
| 
 | ||||
| ### What's changed | ||||
| * Explore images will now be shown based on the map location and not at your current location | ||||
| * Enhanced Wikidata feedback message | ||||
| * Green labels in Explore map will no longer be hidden by other pins thumbnails | ||||
| * Upload wizard's language drop-down now reflects the language used in the pin label | ||||
| * Users can now pick only one image at a time while using the custom selector | ||||
| * Bug fixes and stability improvements  | ||||
| 
 | ||||
| ## v5.4.1 | ||||
| 
 | ||||
| ### What's changed | ||||
| * Custom picker now detects images that are already available on Commons | ||||
| * Improve credit line in image list | ||||
| * Show place cards with loaded names only in the Nearby list | ||||
| * Fix the error that occurs while loading images in Explore | ||||
| 
 | ||||
| ## v5.3.0 | ||||
| 
 | ||||
| ### What's changed | ||||
| * Enable EmailAuth support | ||||
| * Explore map images no longer show "Unknown" | ||||
| * Fix crash when removing last two images of multiupload | ||||
| * Mark ❌ for closed locations (P3999) in Nearby | ||||
| * Fix two pin labels staying visible at the same time in Explore map | ||||
| * Refactoring and minor UI improvements | ||||
| 
 | ||||
| ## v5.2.0 | ||||
| 
 | ||||
| v5.2.0 boasts several new functionalities like: | ||||
| 
 | ||||
| * A new refresh button lets you quickly reload the Nearby map | ||||
| * Bookmarks now support categories | ||||
| * Improved feedback and consistency in the user interface | ||||
| * Bug fixes and performance improvements | ||||
| 
 | ||||
| ### What's changed | ||||
| * Implement "Refresh" button to clear the cache and reload the Nearby map. | ||||
| * `CommonsApplication` migrate to kotlin & some lint fixes. | ||||
| * Revert back to MainScope for database and UI updates and make database operations thread safe. | ||||
| * Hide edit options for logged-out users in Explore screen. | ||||
| * Introduced a button to delete the current folder in custom selector. | ||||
| * Improve Unique File Name Search. | ||||
| * Migration of several modules from Java to Kotlin. | ||||
| * Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins. | ||||
| * Bug fixes and enhancement of Achievements screen. | ||||
| * Show where file is being used on Commons and other wikis. | ||||
| * Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices. | ||||
| * Make dialogs modal and always show the upload icon. | ||||
| * Fix unintentional deletion of subfolders and non-images by custom selector. | ||||
| * Bookmark categories. | ||||
| * Add pull down to refresh in the Contributions screen. | ||||
| * Fix race condition and lag when loading pin details, faster overlay management. | ||||
| * Show cached pins in Nearby even when internet is unavailable | ||||
| 
 | ||||
|  Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0). | ||||
| 
 | ||||
| 
 | ||||
| ## v5.1.2 | ||||
| 
 | ||||
| ### What's changed | ||||
| 
 | ||||
| * Fix the broken category search in the explore screen | ||||
| 
 | ||||
| ## v5.1.1 | ||||
| 
 | ||||
| ### What's changed | ||||
| 
 | ||||
| * Use Android's new EXIF interface to mitigate security issues in old | ||||
|   EXIF interface. | ||||
| * Make the icon that helps view the upload queue always visible as it ensures | ||||
|   that the queue accessible at all times. | ||||
| 
 | ||||
| ## v5.1.0 | ||||
| 
 | ||||
| ### What's Changed | ||||
| 
 | ||||
| * Enhanced **upload queue management** in the Commons app for smoother, sequential | ||||
|   processing, clearer progress tracking, prevention of stuck or duplicate | ||||
|   uploads. As part of this improvement, the "Limited Connection mode" has been | ||||
|   removed. | ||||
| * Added an option in "Nearby" feature enabling users to **provide feedback on | ||||
|   Wikidata items**. Users can report if an item doesn’t exist, is at a different | ||||
|   location, or has other issues, with submissions tagged for easy tracking and | ||||
|   updates. | ||||
| * Improved the "Nearby" feature by splitting the query into two parts for faster | ||||
|   loading and **better performance, especially in areas with dense amount of | ||||
|   places**. This update also resolves issues with pins overlapping place names. | ||||
| * Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to | ||||
|   the app such as adding **"Partial Access" support**. Also includes some minor | ||||
|   refactoring, and replacement of deprecated circular progress bars. | ||||
| * Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs | ||||
|   appeared blank** in the Category Details screen. Resolved by optimizing view | ||||
|   binding handling in the parent fragments. | ||||
| * Fixed an issue where editing depictions removed all other structured data from | ||||
|   images. Now, **only depictions are updated, preserving other associated data**. | ||||
| * Fixed **map centering** in the image upload flow to **use GPS EXIF tag location** | ||||
|   from pictures and ensured "Show in map app" accurately reflects this location. | ||||
| * Fixed navigation **after uploading via Nearby by directing users to the Uploads | ||||
|   activity** instead of returning to Nearby, preventing confusion about needing to | ||||
|   upload again. | ||||
| 
 | ||||
| ### Bug fixes and various changes | ||||
| 
 | ||||
| * Improved the "Nearby" feature to fetch labels based on the user's preferred | ||||
|   language instead of defaulting to English. | ||||
| * Added a legend to the "Nearby" feature indicating pin statuses: red for items | ||||
|   without pictures, green for those with pictures, and grey for items being | ||||
|   checked. A floating action button now allows users to toggle the legend's | ||||
|   visibility. | ||||
| * Fixed an issue where the "Nominate for deletion" option is shown to logged out | ||||
|   users, preventing app errors and crashes. | ||||
| * Updated the regex pattern that filters categories with an year in it to also | ||||
|   filter the 2020s. | ||||
| * Fix an issue where past depictions were not shown as suggestions, despite | ||||
|   being saved correctly. | ||||
| * Fixed an issue in custom image picker where exiting the media preview showed | ||||
|   only the first image and cleared selections. Now, previously selected images | ||||
|   are restored correctly after exiting the preview. This was contributed. | ||||
| * Fixed an issue in custom image picker where scrolling behavior did not | ||||
|   maintain position after exiting fullscreen preview, ensuring users remain at | ||||
|   the same point in their image roll unless actioned images are filtered. This | ||||
|   was contributed. | ||||
| * Fixed Nearby map not showing new pins on map move by removing the 2000m scroll | ||||
|   threshold and adding an 800ms debounce for smoother pin updates when the map | ||||
|   is moved. Queued searches are now canceled on fragment destruction. | ||||
| * Revised author information retrieval to emphasize the custom author name from | ||||
|   the metadata instead of the default registered username. | ||||
| * Enhanced notification classification to properly identify "email" type | ||||
|   notifications and prompting users to check their e-mail inbox when such | ||||
|   notifications are clicked. | ||||
| * Resolved a bug in the language chooser that incorrectly greyed-out previously | ||||
|   selected languages, ensuring only the current language is non-selectable during | ||||
|   image upload. | ||||
| * Resolved pin color update issue in "Nearby" feature where the pin colour | ||||
|   failed to be updated after a successful image upload. | ||||
| 
 | ||||
| What's listed here is only a subset of all the changes. Check the full-list of | ||||
| the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0). | ||||
| Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0) | ||||
| for an exhaustive list of changes and the various contributors who contributed the same. | ||||
| 
 | ||||
| ## v5.0.2 | ||||
| 
 | ||||
| - Enhanced multi-upload functionality with user prompts to clarify that all images would share the | ||||
|   same category and depictions. | ||||
| - Show Wikidata description on currently active Nearby pin to provide more useful information. | ||||
| - Improve the visibility of map markers by dynamically adjusting their colors based on the app's | ||||
|   theme. The map markers will now appear lighter when the app is in dark mode and darker when the | ||||
|   app is in light mode. This change aims to enhance marker visibility and improve the overall user | ||||
|   experience. | ||||
| - Added information on where user feedback is posted, helping users track existing feedback and | ||||
|   monitor their own submissions. | ||||
| - Enhanced the edit location screen of the upload screen by centering the map on the picture's | ||||
|   location from metadata when editing, or on the device's GPS location if metadata is unavailable, | ||||
|   improving accuracy and user experience. | ||||
| - Ensured the 'Add Location' button is renamed to 'Edit Location' when copying the location of a | ||||
|   recently uploaded image, enhancing clarity and user experience. | ||||
| - Added a ProgressBar to the media detail screen to indicate image loading status, enhancing user | ||||
|   experience by showing loading progress until the image is fully loaded. | ||||
| - Fixed an issue where caption and description fields would intermittently disappear when using | ||||
|   voice input, ensuring text remains visible and stable across all entries. | ||||
| - Fixed a crash that occurred when attempting to remove multiple instances of caption/description | ||||
|   fields after initially adding them. | ||||
| - Improve the text in the prompt shown when skipping login to sound more natural. | ||||
| - Modified feedback addition logic to append new sections at the bottom of the page, ensuring | ||||
|   auto-archiving of sections functions correctly on the feedback page. | ||||
| - Resolved issue where the app failed to clear cookies upon logout. | ||||
| 
 | ||||
| ## v5.0.1 | ||||
| 
 | ||||
| Same as v5.0.0 except this fixes some R8 rules to ensure that the release | ||||
| variants of the app work as intended. | ||||
| 
 | ||||
| ## v5.0.0 | ||||
| 
 | ||||
| ### What's Changed | ||||
| 
 | ||||
| - Redesigned the map feature to **replace Mapbox with the osmdroid library**. | ||||
|   Key elements like pin visualization and user-centered display are still | ||||
|   included in this redesign. This is done to guard against possible misuse of | ||||
|   the Mapbox token and, more crucially, to keep the app from becoming dependent | ||||
|   on a service that charges for usage but offers a free tier. | ||||
| 
 | ||||
|   With this change, the app retrieves the map tiles from [Wikimedia maps](https://maps.wikimedia.org). | ||||
| - Add the ability to **export locations of nearby missing pictures in GPX and | ||||
|   KML formats**. This allows users to browse the locations with desired radius | ||||
|   for offline use in their favourite map apps like OsmAnd or Maps.me, enhancing | ||||
|   accessibility  and offline functionality. | ||||
| - **Limited the uploads via the custom image picker** to a maximum of 20. | ||||
| - Added two menu choices for **transparent image backgrounds**, giving users the | ||||
|   option of either a black or white background, increasing adaptability to | ||||
|   various theme settings. | ||||
| 
 | ||||
|   User customization option has been provided with the | ||||
|   ability to save background color selections permanently on a per image basis. | ||||
| - Implemented functionality to **automatically resume uploads** that become | ||||
|   stuck due to app termination or device reboot. | ||||
| - Added a **compass arrow in the Nearby banner** shown in the "Contributions" | ||||
|   screen to guide users towards the nearest item, thus providing the missing | ||||
|   directional cues. The arrow dynamically adjusts based on device rotation, | ||||
|   aligning with the calculated bearing towards the  target location. Further, | ||||
|   the distance and direction are updated as the user moves. | ||||
| - Implemented **voice input feature** for caption and description fields, | ||||
|   enabling users to dictate text directly into these fields. | ||||
| - Improved various flows in the app to **redirect users to the login page** and | ||||
|   display a  persistent message **if their session becomes invalid** due to a | ||||
|   password  change, enhancing user guidance and security measures. | ||||
| 
 | ||||
| ### Revamps and refactorings | ||||
| 
 | ||||
| - **Revamped initial upload screen layout and the description edit screen layout** | ||||
|   for enhanced user experience and ensuring better symmetry in the design. | ||||
| - **Replaced Butterknife with ViewBinding** in various places of the app. | ||||
| - Transferred essential code from **the redundant data-client module** to the | ||||
|   main Commons app code, enabling its integration and facilitating the removal | ||||
|   of the redundant module. Further, convert various parts of the code to Kotlin. | ||||
| - **Revamped the various location permission flows** to ensure consistency for | ||||
|   the sake of a better user experience. | ||||
| 
 | ||||
| ### Bug fixes and various changes | ||||
| 
 | ||||
| - Resolved an issue where paused uploads that were subsequently cancelled were | ||||
|   still being uploaded. | ||||
| - Fixed an issue where some user information such as upload count were not | ||||
|   displayed in the "Contributions" and "Profile" screens. | ||||
| - Fixed the long-standing broken *"Picture of the Day" widget* to restore its | ||||
|   usability. | ||||
| - Resolved an issue where some categories were hidden at the top of Upload | ||||
|   Wizard suggestions. | ||||
| - Resolved an issue where there was a grey empty screen at Upload wizard when | ||||
|   the app was denied the files permission. | ||||
| - Implemented logic to bypass media in Peer Review if the current reviewer is | ||||
|   also the user who uploaded the media. | ||||
| - Corrected arrow image behaviour in the first upload screen: now displays down | ||||
|   arrow when details card is fully visible, aligning with expected user | ||||
|   interaction. | ||||
| - Updated app icon to improve visibility and recognition on F-Droid. | ||||
| - Fixed issue causing all pictures to disappear and activity to reload fully in | ||||
|   the custom image selector after marking a picture as 'not for  upload', now | ||||
|   ensuring only the selected picture is removed as expected. | ||||
| 
 | ||||
| What's listed here is only a subset of all the changes. Check the full-list of | ||||
| the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v4.2.1...v5.0.0). | ||||
| Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.0.0) | ||||
| for an exhaustive list of changes and the various contributors who contributed the same. | ||||
| 
 | ||||
| ## v4.2.1 | ||||
| 
 | ||||
| - Provide the ability to edit an image to losslessly rotate it while uploading | ||||
| - Fix a bug in v4.2.0 where the nearby places were not loading | ||||
| - Fix a bug where editing depictions was showing a progress bar indefinitely | ||||
| - In the upload screen, use different map icons to indicate if image is being uploaded with location | ||||
|   metadata | ||||
| - For nearby uploads, it is no longer possible to deselect the item's category and depiction | ||||
| - The Mapbox account key used by the app has been changed | ||||
| - Category search now shows exact matches without any discrepancies | ||||
| - Various bug and crash fixes | ||||
| 
 | ||||
| ## v4.2.0 | ||||
| - Dark mode colour improvements | ||||
| - Enhancements done to address location metadata loss including the metadata loss that occurs in | ||||
|   latest Android versions | ||||
| - Enhancements done to address the issue where uploads get stuck in queued state | ||||
| - Fix the inability to upload via the in-app camera option | ||||
| - Provide the ability to optionally include location metadata for in-app camera uploads in case the | ||||
|   device camera app does not provide location metadata | ||||
| - Use geo location URL that works consistently across all map applications | ||||
| - Fix crash when clicking on location target icon while trying to edit the location of an upload | ||||
| - Fix crash that occurs randomly while returning to the app after leaving it in the background | ||||
| - Fix crash in Sign up activity on Android version 5.0 and 5.1 | ||||
| - Android 13 compatibility changes | ||||
| 
 | ||||
| ## v4.1.0 | ||||
| - Location of pictures uploaded via custom picture selector are now recognized | ||||
| - Improvements to the custom picture selector | ||||
| - Ensure the WLM pictures are associated with the correct templates for each year | ||||
| - Only show pictures uploaded via app in peer review | ||||
| - Improve the variety of images show in peer review | ||||
| - Allow going to current location in location edit dialog while uploading a picture | ||||
| - Switch to using MapLibre instead of Mapbox and thereby disable telemetry sent to Mapbox | ||||
| - Fixed various bugs | ||||
| 
 | ||||
| ## v4.0.5 | ||||
| - Bumped min SDK to 29 to try and solve Google policy issue | ||||
| - Reverted dialog | ||||
| - Note: This encompasses versions 1031, 1032, and 1033, due to the Play Store's requirements to overwrite all the tracks with a post-fix version (otherwise no single track can be published) | ||||
| 
 | ||||
| ## v4.0.4 | ||||
| - Added dialog for Google's location policy | ||||
| 
 | ||||
| ## v4.0.3 | ||||
| - Added "Report" button for Google UGC policy | ||||
| 
 | ||||
| ## v4.0.2 | ||||
| - Fixed bug with wrong dates taken from EXIF | ||||
| - Fixed various crashes | ||||
| 
 | ||||
| ## v4.0.1 | ||||
| - Fixed bug with no browser found | ||||
| - Updated Mapbox SDK to fix hamburger crash | ||||
| 
 | ||||
| ## v4.0.0 | ||||
| - Added map showing nearby Commons pictures | ||||
| - Added custom SPARQL queries | ||||
|  |  | |||
							
								
								
									
										1
									
								
								CREDITS
									
										
									
									
									
								
							
							
						
						
									
										1
									
								
								CREDITS
									
										
									
									
									
								
							|  | @ -53,6 +53,7 @@ their contribution to the product. | |||
| * Butterknife | ||||
| * GSON | ||||
| * Timber | ||||
| * MapBox | ||||
| 
 | ||||
| 3rd party open source apps from which significant code has been reused: | ||||
| * Android Wikipedia app https://github.com/wikimedia/apps-android-wikipedia | ||||
|  |  | |||
							
								
								
									
										19
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										19
									
								
								README.md
									
										
									
									
									
								
							|  | @ -1,12 +1,12 @@ | |||
| # Wikimedia Commons Android app | ||||
|  | ||||
| [](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain) | ||||
| [](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amaster) | ||||
| [](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw) | ||||
| [](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> | ||||
|  | @ -15,7 +15,7 @@ Initially started by the Wikimedia Foundation, this app is now maintained by gra | |||
| 
 | ||||
| ## Documentation | ||||
| 
 | ||||
| Our [documentation repository][4] contains extensive documentation for users, contributors, and developers alike: | ||||
| We try to have an extensive documentation at our [documentation repository][4]: | ||||
| 
 | ||||
| * [User Documentation][5] | ||||
| * [Contributor Documentation][6] | ||||
|  | @ -29,12 +29,11 @@ Thank you all for your work! | |||
| 
 | ||||
| | [<img src="https://avatars.githubusercontent.com/u/3611199?v=4" width="100px;"/><br /><sub><b>misaochan</b></sub>](https://github.com/misaochan) | [<img src="https://avatars.githubusercontent.com/u/24829418?v=4" width="100px;"/><br /><sub><b>translatewiki</b></sub>](https://github.com/translatewiki) | [<img src="https://avatars.githubusercontent.com/u/3127881?v=4" width="100px;"/><br /><sub><b>neslihanturan</b></sub>](https://github.com/neslihanturan) | [<img src="https://avatars.githubusercontent.com/u/30430?v=4" width="100px;"/><br /><sub><b>yuvipanda</b></sub>](https://github.com/yuvipanda) | [<img src="https://avatars.githubusercontent.com/u/99590?v=4" width="100px;"/><br /><sub><b>nicolas-raoul</b></sub>](https://github.com/nicolas-raoul) | | ||||
| | :---: | :---: | :---: | :---: | :---: | | ||||
| | [<img src="https://avatars.githubusercontent.com/u/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://avatars.githubusercontent.com/u/4953590?v=4" width="100px;"/><br /><sub><b>domdomegg</b></sub>](https://github.com/domdomegg) | [<img src="https://avatars.githubusercontent.com/u/3069373?v=4" width="100px;"/><br /><sub><b>maskaravivek</b></sub>](https://github.com/maskaravivek) | [<img src="https://avatars.githubusercontent.com/u/407647?v=4" width="100px;"/><br /><sub><b>psh</b></sub>](https://github.com/psh) | [<img src="https://avatars.githubusercontent.com/u/103075?v=4" width="100px;"/><br /><sub><b>brion</b></sub>](https://github.com/brion) | [<img src="https://avatars.githubusercontent.com/u/17375274?v=4" width="100px;"/><br /><sub><b>ashishkumar468</b></sub>](https://github.com/ashishkumar468) | | ||||
| | [<img src="https://avatars.githubusercontent.com/u/10674?v=4" width="100px;"/><br /><sub><b>whym</b></sub>](https://github.com/whym) | [<img src="https://avatars.githubusercontent.com/u/10153800?v=4" width="100px;"/><br /><sub><b>akaita</b></sub>](https://github.com/akaita) | [<img src="https://avatars.githubusercontent.com/u/30932899?v=4" width="100px;"/><br /><sub><b>madhurgupta10</b></sub>](https://github.com/madhurgupta10) | [<img src="https://avatars.githubusercontent.com/u/6900601?v=4" width="100px;"/><br /><sub><b>veyndan</b></sub>](https://github.com/veyndan) | [<img src="https://avatars.githubusercontent.com/u/19607555?v=4" width="100px;"/><br /><sub><b>ujjwalagrawal17</b></sub>](https://github.com/ujjwalagrawal17) | | ||||
| | [<img src="https://avatars.githubusercontent.com/u/3358282?v=4" width="100px;"/><br /><sub><b>macgills</b></sub>](https://github.com/macgills) | [<img src="https://avatars.githubusercontent.com/u/1682214?v=4" width="100px;"/><br /><sub><b>dbrant</b></sub>](https://github.com/dbrant) | [<img src="https://avatars.githubusercontent.com/u/34261945?v=4" width="100px;"/><br /><sub><b>vanshikaarora</b></sub>](https://github.com/vanshikaarora) | [<img src="https://avatars.githubusercontent.com/u/1345681?v=4" width="100px;"/><br /><sub><b>sandarumk</b></sub>](https://github.com/sandarumk) | [<img src="https://avatars.githubusercontent.com/u/29161745?v=4" width="100px;"/><br /><sub><b>tanvidadu</b></sub>](https://github.com/tanvidadu) | | ||||
| | [<img src="https://avatars.githubusercontent.com/u/39745544?v=4" width="100px;"/><br /><sub><b>cypherop</b></sub>](https://github.com/cypherop) | [<img src="https://avatars.githubusercontent.com/u/6953323?v=4" width="100px;"/><br /><sub><b>tobias47n9e</b></sub>](https://github.com/tobias47n9e) | [<img src="https://avatars.githubusercontent.com/u/25305892?v=4" width="100px;"/><br /><sub><b>hismaeel</b></sub>](https://github.com/hismaeel) | [<img src="https://avatars.githubusercontent.com/u/12574756?v=4" width="100px;"/><br /><sub><b>tshradheya</b></sub>](https://github.com/tshradheya) | [<img src="https://avatars.githubusercontent.com/u/3308769?v=4" width="100px;"/><br /><sub><b>addshore</b></sub>](https://github.com/addshore) | | ||||
| | [<img src="https://avatars.githubusercontent.com/u/20313518?v=4" width="100px;"/><br /><sub><b>knight-shade</b></sub>](https://github.com/knight-shade) | [<img src="https://avatars.githubusercontent.com/u/210297?v=4" width="100px;"/><br /><sub><b>siebrand</b></sub>](https://github.com/siebrand) | [<img src="https://avatars.githubusercontent.com/u/12448084?v=4" width="100px;"/><br /><sub><b>sivaraam</b></sub>](https://github.com/sivaraam) | [<img src="https://avatars.githubusercontent.com/u/5329780?v=4" width="100px;"/><br /><sub><b>Bluesir9</b></sub>](https://github.com/Bluesir9) | [<img src="https://avatars.githubusercontent.com/u/44129798?v=4" width="100px;"/><br /><sub><b>kbhardwaj123</b></sub>](https://github.com/kbhardwaj123) | | ||||
| 
 | ||||
| 
 | ||||
| .. and [many more](https://github.com/commons-app/apps-android-commons/graphs/contributors). | ||||
|  | @ -46,7 +45,7 @@ This software is open source, licensed under the [Apache License 2.0][10]. | |||
| 
 | ||||
| [1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons | ||||
| [2]: https://commons-app.github.io/ | ||||
| [3]: https://github.com/commons-app/apps-android-commons/issues?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 | ||||
|  |  | |||
							
								
								
									
										375
									
								
								app/build.gradle
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										375
									
								
								app/build.gradle
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,375 @@ | |||
| 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 from: "$rootDir/jacoco.gradle" | ||||
| 
 | ||||
| def isRunningOnTravisAndIsNotPRBuild = System.getenv("CI") == "true" && file('../play.p12').exists() | ||||
| 
 | ||||
| if (isRunningOnTravisAndIsNotPRBuild) { | ||||
|     apply plugin: 'com.github.triplet.play' | ||||
| } | ||||
| 
 | ||||
| dependencies { | ||||
| 
 | ||||
|     implementation project(':wikimedia-data-client') | ||||
|     // Utils | ||||
|     implementation 'in.yuvi:http.fluent:1.3' | ||||
|     implementation 'com.google.code.gson:gson:2.8.5' | ||||
|     implementation ("com.squareup.okhttp3:okhttp:$OKHTTP_VERSION"){ | ||||
|         force = true //API 19 support | ||||
|     } | ||||
|     implementation 'com.squareup.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.rxbinding3:rxbinding-appcompat:3.0.0' | ||||
|     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' | ||||
| 
 | ||||
|     // 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:9.1.0' | ||||
|     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.mapbox.mapboxsdk:mapbox-android-telemetry:6.1.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" | ||||
|     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' | ||||
| 
 | ||||
|     kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION" | ||||
|     implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" | ||||
|     implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" | ||||
|     implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION" | ||||
|     testImplementation "androidx.paging:paging-common-ktx:$PAGING_VERSION" | ||||
|     implementation "androidx.paging:paging-rxjava2-ktx:$PAGING_VERSION" | ||||
|     implementation "androidx.recyclerview:recyclerview:1.2.0-alpha02" | ||||
|     implementation "com.squareup.okhttp3:okhttp-ws:$OKHTTP_VERSION" | ||||
| 
 | ||||
|     // Logging | ||||
|     implementation 'ch.acra:acra-dialog:5.8.4' | ||||
|     implementation 'ch.acra:acra-mail:5.8.4' | ||||
|     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:$OKHTTP_VERSION" | ||||
| 
 | ||||
|     // Dependency injector | ||||
|     implementation "com.google.dagger:dagger-android:$DAGGER_VERSION" | ||||
|     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" | ||||
|     annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" | ||||
| 
 | ||||
|     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION" | ||||
|     implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION" | ||||
| 
 | ||||
|     //Mocking | ||||
|     testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0' | ||||
|     testImplementation 'org.mockito:mockito-inline:2.13.0' | ||||
|     testImplementation 'org.mockito:mockito-core:2.25.1' | ||||
|     testImplementation "org.powermock:powermock-module-junit4:2.0.2" | ||||
|     testImplementation "org.powermock:powermock-api-mockito2:2.0.2" | ||||
| 
 | ||||
|     // Unit testing | ||||
|     testImplementation 'junit:junit:4.13.2' | ||||
|     testImplementation 'org.robolectric:robolectric:4.6-alpha-1' | ||||
|     testImplementation 'androidx.test:core:1.4.0' | ||||
|     testImplementation "com.squareup.okhttp3:mockwebserver:$OKHTTP_VERSION" | ||||
|     testImplementation "com.jraska.livedata:testing-ktx:1.1.2" | ||||
|     testImplementation "androidx.arch.core:core-testing:2.1.0" | ||||
|     testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.0" | ||||
|     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.0" | ||||
|     testImplementation 'com.facebook.soloader:soloader:0.10.1' | ||||
|     testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0" | ||||
| 
 | ||||
|     // Android testing | ||||
|     androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION" | ||||
|     androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha04' | ||||
|     androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' | ||||
|     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0-alpha04' | ||||
|     androidTestImplementation 'androidx.test:runner:1.4.0' | ||||
|     androidTestImplementation 'androidx.test:rules:1.4.1-alpha04' | ||||
|     androidTestImplementation 'androidx.test:core:1.4.0' | ||||
|     androidTestImplementation 'androidx.test.ext:junit:1.1.3' | ||||
|     androidTestImplementation 'androidx.annotation:annotation:1.3.0' | ||||
|     androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.8.0' | ||||
|     androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0" | ||||
|     androidTestUtil 'androidx.test:orchestrator:1.4.1' | ||||
| 
 | ||||
|     // 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.3.0" | ||||
|     implementation "androidx.cardview:cardview:1.0.0" | ||||
|     implementation 'androidx.constraintlayout:constraintlayout:1.1.3' | ||||
|     implementation "androidx.exifinterface:exifinterface:1.3.2" | ||||
|     implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" | ||||
|     implementation "androidx.multidex:multidex:2.0.1" | ||||
| 
 | ||||
|     //swipe_layout | ||||
|     implementation 'com.daimajia.swipelayout:library:1.2.0@aar' | ||||
| 
 | ||||
|     //Room | ||||
|     implementation "androidx.room:room-runtime:$ROOM_VERSION" | ||||
|     implementation "androidx.room:room-ktx:$ROOM_VERSION" | ||||
|     implementation "androidx.room:room-rxjava2:$ROOM_VERSION" | ||||
|     kapt "androidx.room:room-compiler:$ROOM_VERSION" | ||||
|     // For Kotlin use kapt instead of annotationProcessor | ||||
|     implementation 'com.squareup.retrofit2:retrofit:2.8.1' | ||||
|     testImplementation "androidx.arch.core:core-testing:2.1.0" | ||||
| 
 | ||||
|     // Pref | ||||
|     // Java language implementation | ||||
|     implementation "androidx.preference:preference:$PREFERENCE_VERSION" | ||||
|     // Kotlin | ||||
|     implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION" | ||||
| 
 | ||||
|     implementation "androidx.multidex:multidex:$MULTIDEX_VERSION" | ||||
| 
 | ||||
|     def work_version = "2.6.0" | ||||
|     // Kotlin + coroutines | ||||
|     implementation "androidx.work:work-runtime-ktx:$work_version" | ||||
|     testImplementation "androidx.work:work-testing:$work_version" | ||||
| 
 | ||||
|     //Glide | ||||
|     implementation 'com.github.bumptech.glide:glide:4.12.0' | ||||
|     annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' | ||||
| 
 | ||||
|     implementation("io.github.coordinates2country:coordinates2country-android:1.3") {  exclude group: 'com.google.android', module: 'android' } | ||||
| } | ||||
| 
 | ||||
| task disableAnimations(type: Exec) { | ||||
|     def adb = "$System.env.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 { | ||||
|     connectedBetaDebugAndroidTest.dependsOn disableAnimations | ||||
|     connectedProdDebugAndroidTest.dependsOn disableAnimations | ||||
| } | ||||
| 
 | ||||
| android { | ||||
|     compileSdkVersion 30 | ||||
| 
 | ||||
|     defaultConfig { | ||||
|         //applicationId 'fr.free.nrw.commons' | ||||
| 
 | ||||
|         versionCode 1026 | ||||
|         versionName '4.0.0' | ||||
|         setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) | ||||
| 
 | ||||
|         minSdkVersion 19 | ||||
|         targetSdkVersion 30 | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
|         testInstrumentationRunnerArguments clearPackageData: 'true' | ||||
| 
 | ||||
|         multiDexEnabled true | ||||
| 
 | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
| 
 | ||||
|         vectorDrawables.useSupportLibrary = true | ||||
|     } | ||||
| 
 | ||||
|     packagingOptions { | ||||
|         exclude 'META-INF/androidx.*' | ||||
|         exclude 'META-INF/proguard/androidx-annotations.pro' | ||||
|     } | ||||
| 
 | ||||
|     testOptions { | ||||
|         animationsDisabled true | ||||
| 
 | ||||
|         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 false | ||||
|             testCoverageEnabled true | ||||
|             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.1.0' | ||||
|         exclude module: 'okhttp-ws' | ||||
|     } | ||||
|     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", "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", "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", "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\"" | ||||
| 
 | ||||
|             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", "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", "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", "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\"" | ||||
| 
 | ||||
|             dimension 'tier' | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     lintOptions { | ||||
|         disable 'MissingTranslation' | ||||
|         disable 'ExtraTranslation' | ||||
|         abortOnError false | ||||
|     } | ||||
| 
 | ||||
|     compileOptions { | ||||
|         sourceCompatibility JavaVersion.VERSION_1_8 | ||||
|         targetCompatibility JavaVersion.VERSION_1_8 | ||||
|     } | ||||
| 
 | ||||
|     buildToolsVersion buildToolsVersion | ||||
| 
 | ||||
|     buildFeatures { | ||||
|         viewBinding true | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| String getTestUserName() { | ||||
|     def propFile = rootProject.file("./local.properties") | ||||
|     def properties = new Properties() | ||||
|     properties.load(new FileInputStream(propFile)) | ||||
|     return properties['TEST_USER_NAME'] | ||||
| } | ||||
| 
 | ||||
| String getTestPassword() { | ||||
|     def propFile = rootProject.file("./local.properties") | ||||
|     def properties = new Properties() | ||||
|     properties.load(new FileInputStream(propFile)) | ||||
|     return properties['TEST_USER_PASSWORD'] | ||||
| } | ||||
| 
 | ||||
| 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 | ||||
| } | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class AboutActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) | ||||
| 
 | ||||
|  | @ -35,8 +36,7 @@ class AboutActivityTest { | |||
|         device.setOrientationNatural() | ||||
|         device.freezeRotation() | ||||
|         Intents.init() | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -47,12 +47,11 @@ class AboutActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun testBuildNumber() { | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.about_version)) | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_version)) | ||||
|             .check( | ||||
|                 ViewAssertions.matches( | ||||
|                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()), | ||||
|                 ), | ||||
|                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()) | ||||
|                 ) | ||||
|             ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -62,8 +61,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.WEBSITE_URL), | ||||
|             ), | ||||
|                 IntentMatchers.hasData(Urls.WEBSITE_URL) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -74,8 +73,8 @@ class AboutActivityTest { | |||
|             CoreMatchers.anyOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), | ||||
|                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME), | ||||
|             ), | ||||
|                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -85,8 +84,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL), | ||||
|             ), | ||||
|                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -96,8 +95,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL), | ||||
|             ), | ||||
|                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -105,12 +104,12 @@ class AboutActivityTest { | |||
|     fun testLaunchTranslate() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) | ||||
|         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) | ||||
|         val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] | ||||
|         val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0] | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"), | ||||
|             ), | ||||
|                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode") | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -120,30 +119,27 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.CREDITS_URL), | ||||
|             ), | ||||
|                 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.USER_GUIDE_URL))) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     @Test | ||||
|     fun testLaunchAboutFaq() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.FAQ_URL), | ||||
|             ), | ||||
|                 IntentMatchers.hasData(Urls.FAQ_URL) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -18,14 +18,12 @@ import fr.free.nrw.commons.auth.LoginActivity | |||
| import fr.free.nrw.commons.auth.SignupActivity | ||||
| import org.hamcrest.CoreMatchers | ||||
| import org.hamcrest.CoreMatchers.not | ||||
| import org.junit.After | ||||
| import org.junit.Before | ||||
| import org.junit.Rule | ||||
| import org.junit.Test | ||||
| import org.junit.* | ||||
| import org.junit.runner.RunWith | ||||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class LoginActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = ActivityTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -51,8 +49,8 @@ class LoginActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL), | ||||
|             ), | ||||
|                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -21,23 +21,20 @@ import fr.free.nrw.commons.kvstore.JsonKvStore | |||
| import fr.free.nrw.commons.notification.NotificationActivity | ||||
| import org.hamcrest.CoreMatchers | ||||
| import org.hamcrest.Matchers | ||||
| import org.junit.After | ||||
| import org.junit.Before | ||||
| import org.junit.Rule | ||||
| import org.junit.Test | ||||
| import org.junit.* | ||||
| 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", | ||||
|         ) | ||||
|     var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( | ||||
|         "android.permission.ACCESS_FINE_LOCATION" | ||||
|     ) | ||||
| 
 | ||||
|     private val device: UiDevice = | ||||
|         UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) | ||||
|  | @ -51,8 +48,7 @@ class MainActivityTest { | |||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents.init() | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|         val context = InstrumentationRegistry.getInstrumentation().targetContext | ||||
|         val storeName = context.packageName + "_preferences" | ||||
|  | @ -66,149 +62,169 @@ class MainActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun testNearby() { | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         1, | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     1 | ||||
|                 ), | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|                 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"), | ||||
|         val actionMenuItemView2 = Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.list_sheet), ViewMatchers.withContentDescription("List"), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         0, | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         actionMenuItemView2.perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testExplore() { | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         2, | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     2 | ||||
|                 ), | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|                 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( | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         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(), | ||||
|                     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( | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         3, | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     3 | ||||
|                 ), | ||||
|             ).perform(ViewActions.click()) | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testNotifications() { | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withId(R.id.notifications), | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.notifications), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         1, | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     1 | ||||
|                 ), | ||||
|             ).perform(ViewActions.click()) | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) | ||||
|         Espresso.pressBack() | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testLimitedConnectionModeToggle() { | ||||
|         val isEnabled = defaultKvStore | ||||
|             .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.toggle_limited_connection_mode), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                     ), | ||||
|                     0 | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(1000) | ||||
|         if (isEnabled) { | ||||
|             Assert.assertFalse( | ||||
|                 defaultKvStore | ||||
|                     .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) | ||||
|             ) | ||||
|         } else { | ||||
|             Assert.assertTrue( | ||||
|                 defaultKvStore | ||||
|                     .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -4,6 +4,7 @@ import android.app.Activity | |||
| import android.app.Instrumentation | ||||
| import androidx.test.espresso.Espresso.onView | ||||
| import androidx.test.espresso.action.ViewActions | ||||
| import androidx.test.espresso.action.ViewActions.swipeRight | ||||
| import androidx.test.espresso.intent.Intents | ||||
| import androidx.test.espresso.intent.matcher.IntentMatchers | ||||
| import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent | ||||
|  | @ -25,6 +26,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class ProfileActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = IntentsTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -36,8 +38,7 @@ class ProfileActivityTest { | |||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -49,19 +50,20 @@ class ProfileActivityTest { | |||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0, | ||||
|                         0 | ||||
|                     ), | ||||
|                     4, | ||||
|                     4 | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed(), | ||||
|             ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         onView(Matchers.allOf(withId(R.id.more_profile))).perform( | ||||
|             ViewActions.scrollTo(), | ||||
|             ViewActions.click(), | ||||
|             ViewActions.click() | ||||
|         ) | ||||
|         device.swipe(1033, 1346, 531, 1346, 20) | ||||
|         device.swipe(1033,1346,531,1346,20) | ||||
|         UITestHelper.sleep(5000) | ||||
|         Intents.intended(hasComponent(ProfileActivity::class.java.name)) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class ReviewActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) | ||||
| 
 | ||||
|  | @ -16,4 +17,5 @@ class ReviewActivityTest { | |||
|     fun orientationChange() { | ||||
|         UITestHelper.changeOrientation(activityRule) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -16,6 +16,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SearchActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = ActivityTestRule(SearchActivity::class.java) | ||||
| 
 | ||||
|  | @ -30,22 +31,21 @@ class SearchActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun exploreActivityTest() { | ||||
|         val searchAutoComplete = | ||||
|             Espresso.onView( | ||||
|                 Matchers.allOf( | ||||
|                     UITestHelper.childAtPosition( | ||||
|                         Matchers.allOf( | ||||
|         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")), | ||||
|                             UITestHelper.childAtPosition( | ||||
|                                 ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||
|                                 1, | ||||
|                             ), | ||||
|                         ), | ||||
|                         0, | ||||
|                             1 | ||||
|                         ) | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) | ||||
|         UITestHelper.sleep(5000) | ||||
|         device.swipe(1000, 1400, 500, 1400, 20) | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SettingsActivityLoggedInTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -34,32 +35,31 @@ class SettingsActivityLoggedInTest { | |||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testSettings() { | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withContentDescription("More"), | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withContentDescription("More"), | ||||
|                 UITestHelper.childAtPosition( | ||||
|                     UITestHelper.childAtPosition( | ||||
|                         UITestHelper.childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         4, | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                     4 | ||||
|                 ), | ||||
|             ).perform(ViewActions.click()) | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( | ||||
|             ViewActions.scrollTo(), | ||||
|             ViewActions.click(), | ||||
|             ViewActions.click() | ||||
|         ) | ||||
|         Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -23,6 +23,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SettingsActivityTest { | ||||
| 
 | ||||
|     private lateinit var defaultKvStore: JsonKvStore | ||||
| 
 | ||||
|     @get:Rule | ||||
|  | @ -43,24 +44,22 @@ class SettingsActivityTest { | |||
|     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()), | ||||
|                 ) | ||||
|         } | ||||
|         // Check authorName preference is enabled | ||||
|         Espresso | ||||
|             .onView( | ||||
|             Espresso.onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.recycler_view), | ||||
|                     childAtPosition(withId(android.R.id.list_container), 0), | ||||
|                 ), | ||||
|             ).check(matches(isEnabled())) | ||||
|                     childAtPosition(withId(android.R.id.list_container), 0) | ||||
|                 ) | ||||
|             ).perform( | ||||
|                 RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()) | ||||
|             ) | ||||
|         } | ||||
|         // Check authorName preference is enabled | ||||
|         Espresso.onView( | ||||
|             allOf( | ||||
|                 withId(R.id.recycler_view), | ||||
|                 childAtPosition(withId(android.R.id.list_container), 0) | ||||
|             ) | ||||
|         ).check(matches(isEnabled())) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|  |  | |||
|  | @ -10,20 +10,17 @@ import androidx.test.espresso.action.ViewActions | |||
| import androidx.test.espresso.matcher.ViewMatchers | ||||
| import androidx.test.rule.ActivityTestRule | ||||
| import org.apache.commons.lang3.StringUtils | ||||
| import org.hamcrest.BaseMatcher | ||||
| import org.hamcrest.Description | ||||
| import org.hamcrest.Matcher | ||||
| import org.hamcrest.Matchers | ||||
| import org.hamcrest.TypeSafeMatcher | ||||
| import org.hamcrest.* | ||||
| 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) { | ||||
|  | @ -32,31 +29,27 @@ class UITestHelper { | |||
| 
 | ||||
|         fun skipLogin() { | ||||
|             try { | ||||
|                 // Skip Login | ||||
|                 val htmlTextView = | ||||
|                     onView( | ||||
|                         Matchers.allOf( | ||||
|                             ViewMatchers.withId(R.id.skip_login), | ||||
|                             ViewMatchers.withText("Skip"), | ||||
|                             ViewMatchers.isDisplayed(), | ||||
|                         ), | ||||
|                 //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"), | ||||
|                 val appCompatButton = onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), | ||||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 childAtPosition( | ||||
|                                     ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                     0, | ||||
|                                 ), | ||||
|                                 3, | ||||
|                                 ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                 0 | ||||
|                             ), | ||||
|                         ), | ||||
|                             3 | ||||
|                         ) | ||||
|                     ) | ||||
|                 ) | ||||
|                 appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|             } | ||||
|  | @ -64,18 +57,18 @@ class UITestHelper { | |||
| 
 | ||||
|         fun loginUser() { | ||||
|             try { | ||||
|                 // Perform Login | ||||
|                 //Perform Login | ||||
|                 sleep(3000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_username)) | ||||
|                     .perform( | ||||
|                         ViewActions.replaceText(getTestUsername()), | ||||
|                         ViewActions.closeSoftKeyboard(), | ||||
|                         ViewActions.closeSoftKeyboard() | ||||
|                     ) | ||||
|                 sleep(2000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_password)) | ||||
|                     .perform( | ||||
|                         ViewActions.replaceText(getTestUserPassword()), | ||||
|                         ViewActions.closeSoftKeyboard(), | ||||
|                         ViewActions.closeSoftKeyboard() | ||||
|                     ) | ||||
|                 sleep(2000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_button)) | ||||
|  | @ -83,6 +76,7 @@ class UITestHelper { | |||
|                 sleep(10000) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         fun logoutUser() { | ||||
|  | @ -93,38 +87,36 @@ class UITestHelper { | |||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                                 0, | ||||
|                                 0 | ||||
|                             ), | ||||
|                             4, | ||||
|                             4 | ||||
|                         ), | ||||
|                         ViewMatchers.isDisplayed(), | ||||
|                     ), | ||||
|                         ViewMatchers.isDisplayed() | ||||
|                     ) | ||||
|                 ).perform(ViewActions.click()) | ||||
|                 onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(R.id.more_logout), | ||||
|                         ViewMatchers.withText("Logout"), | ||||
|                         ViewMatchers.withId(R.id.more_logout), ViewMatchers.withText("Logout"), | ||||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), | ||||
|                                 0, | ||||
|                                 0 | ||||
|                             ), | ||||
|                             6, | ||||
|                         ), | ||||
|                     ), | ||||
|                             6 | ||||
|                         ) | ||||
|                     ) | ||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|                 onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(android.R.id.button1), | ||||
|                         ViewMatchers.withText("Yes"), | ||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), | ||||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                 0, | ||||
|                                 0 | ||||
|                             ), | ||||
|                             3, | ||||
|                         ), | ||||
|                     ), | ||||
|                             3 | ||||
|                         ) | ||||
|                     ) | ||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|                 sleep(5000) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|  | @ -132,9 +124,9 @@ class UITestHelper { | |||
|         } | ||||
| 
 | ||||
|         fun childAtPosition( | ||||
|             parentMatcher: Matcher<View>, | ||||
|             position: Int, | ||||
|             parentMatcher: Matcher<View>, position: Int | ||||
|         ): Matcher<View> { | ||||
| 
 | ||||
|             return object : TypeSafeMatcher<View>() { | ||||
|                 override fun describeTo(description: Description) { | ||||
|                     description.appendText("Child at position $position in parent ") | ||||
|  | @ -143,9 +135,8 @@ class UITestHelper { | |||
| 
 | ||||
|                 public override fun matchesSafely(view: View): Boolean { | ||||
|                     val parent = view.parent | ||||
|                     return parent is ViewGroup && | ||||
|                         parentMatcher.matches(parent) && | ||||
|                         view == parent.getChildAt(position) | ||||
|                     return parent is ViewGroup && parentMatcher.matches(parent) | ||||
|                             && view == parent.getChildAt(position) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | @ -163,18 +154,14 @@ 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>) { | ||||
|  | @ -187,7 +174,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 | ||||
|  |  | |||
|  | @ -4,10 +4,7 @@ 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.action.ViewActions.* | ||||
| import androidx.test.espresso.contrib.RecyclerViewActions | ||||
| import androidx.test.espresso.intent.Intents | ||||
| import androidx.test.espresso.intent.matcher.IntentMatchers | ||||
|  | @ -18,7 +15,7 @@ import androidx.test.platform.app.InstrumentationRegistry | |||
| import androidx.test.rule.ActivityTestRule | ||||
| import androidx.test.rule.GrantPermissionRule | ||||
| import androidx.test.uiautomator.UiDevice | ||||
| import fr.free.nrw.commons.locationpicker.LocationPickerActivity | ||||
| import fr.free.nrw.commons.LocationPicker.LocationPickerActivity | ||||
| import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition | ||||
| import fr.free.nrw.commons.auth.LoginActivity | ||||
| import org.hamcrest.CoreMatchers | ||||
|  | @ -31,6 +28,7 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class UploadCancelledTest { | ||||
| 
 | ||||
|     @Rule | ||||
|     @JvmField | ||||
|     var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) | ||||
|  | @ -39,7 +37,7 @@ class UploadCancelledTest { | |||
|     @JvmField | ||||
|     var mGrantPermissionRule: GrantPermissionRule = | ||||
|         GrantPermissionRule.grant( | ||||
|             "android.permission.WRITE_EXTERNAL_STORAGE", | ||||
|             "android.permission.WRITE_EXTERNAL_STORAGE" | ||||
|         ) | ||||
| 
 | ||||
|     private val device: UiDevice = | ||||
|  | @ -49,15 +47,15 @@ class UploadCancelledTest { | |||
|     fun setup() { | ||||
|         try { | ||||
|             Intents.init() | ||||
|         } catch (_: IllegalStateException) { | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|         device.unfreezeRotation() | ||||
|         device.setOrientationNatural() | ||||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -65,138 +63,131 @@ class UploadCancelledTest { | |||
|     fun teardown() { | ||||
|         try { | ||||
|             Intents.release() | ||||
|         } catch (_: IllegalStateException) { | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun uploadCancelledAfterLocationPickedTest() { | ||||
|         val bottomNavigationItemView = | ||||
|             onView( | ||||
|                 allOf( | ||||
| 
 | ||||
|         val bottomNavigationItemView = onView( | ||||
|             allOf( | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         1, | ||||
|                         withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     1 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         bottomNavigationItemView.perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(12000) | ||||
| 
 | ||||
|         val actionMenuItemView = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.list_sheet), | ||||
|         val actionMenuItemView = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.list_sheet), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         0, | ||||
|                         withId(R.id.toolbar), | ||||
|                         1 | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         actionMenuItemView.perform(click()) | ||||
| 
 | ||||
|         val recyclerView = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.rv_nearby_list), | ||||
|                 ), | ||||
|         val recyclerView = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.rv_nearby_list), | ||||
|             ) | ||||
|         ) | ||||
|         recyclerView.perform( | ||||
|             RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( | ||||
|                 0, | ||||
|                 click(), | ||||
|             ), | ||||
|                 click() | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         val linearLayout3 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.cameraButton), | ||||
|                     childAtPosition( | ||||
|                         allOf( | ||||
|                             withId(R.id.nearby_button_layout), | ||||
|                         ), | ||||
|                         0, | ||||
|         val linearLayout3 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.cameraButton), | ||||
|                 childAtPosition( | ||||
|                     allOf( | ||||
|                         withId(R.id.nearby_button_layout), | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         linearLayout3.perform(click()) | ||||
| 
 | ||||
|         val pasteSensitiveTextInputEditText = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.caption_item_edit_text), | ||||
|         val pasteSensitiveTextInputEditText = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.caption_item_edit_text), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.caption_item_edit_text_input_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                         withId(R.id.caption_item_edit_text_input_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) | ||||
| 
 | ||||
|         val pasteSensitiveTextInputEditText2 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.description_item_edit_text), | ||||
|         val pasteSensitiveTextInputEditText2 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.description_item_edit_text), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.description_item_edit_text_input_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                         withId(R.id.description_item_edit_text_input_layout), | ||||
|                         0 | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     0 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) | ||||
| 
 | ||||
|         val appCompatButton2 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.btn_next), | ||||
|         val appCompatButton2 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.btn_next), | ||||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.ll_container_media_detail), | ||||
|                             2, | ||||
|                         ), | ||||
|                         1, | ||||
|                         withId(R.id.ll_container_media_detail), | ||||
|                         2 | ||||
|                     ), | ||||
|                     isDisplayed(), | ||||
|                     1 | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         appCompatButton2.perform(click()) | ||||
| 
 | ||||
|         val appCompatButton3 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(android.R.id.button1), | ||||
|                 ), | ||||
|         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(), | ||||
|                 ), | ||||
|         val floatingActionButton3 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.location_chosen_button), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         UITestHelper.sleep(2000) | ||||
|         floatingActionButton3.perform(click()) | ||||
|     } | ||||
|  |  | |||
|  | @ -19,10 +19,7 @@ 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.espresso.matcher.ViewMatchers.* | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||
| import androidx.test.filters.LargeTest | ||||
| import androidx.test.rule.ActivityTestRule | ||||
|  | @ -32,29 +29,21 @@ import fr.free.nrw.commons.upload.UploadMediaDetailAdapter | |||
| import fr.free.nrw.commons.util.MyViewAction | ||||
| import fr.free.nrw.commons.utils.ConfigUtils | ||||
| import org.hamcrest.core.AllOf.allOf | ||||
| import org.junit.After | ||||
| import org.junit.Before | ||||
| import org.junit.Ignore | ||||
| import org.junit.Rule | ||||
| import org.junit.Test | ||||
| import org.junit.* | ||||
| import org.junit.runner.RunWith | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| import java.io.FileOutputStream | ||||
| import java.io.IOException | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Date | ||||
| import java.util.Random | ||||
| 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,7 +60,8 @@ class UploadTest { | |||
|     fun setup() { | ||||
|         try { | ||||
|             Intents.init() | ||||
|         } catch (_: IllegalStateException) { | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|  | @ -104,13 +94,14 @@ class UploadTest { | |||
|         dismissWarning("Yes") | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
|                 .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 +109,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,8 +139,8 @@ class UploadTest { | |||
|     private fun dismissWarning(warningText: String) { | ||||
|         try { | ||||
|             onView(withText(warningText)) | ||||
|                 .check(matches(isDisplayed())) | ||||
|                 .perform(click()) | ||||
|                     .check(matches(isDisplayed())) | ||||
|                     .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
|     } | ||||
|  | @ -177,10 +167,10 @@ class UploadTest { | |||
|         dismissWarning("Yes") | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
|                 .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|             .perform(click()) | ||||
|                 .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(10000) | ||||
|         dismissWarning("Yes") | ||||
|  | @ -188,30 +178,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") | ||||
|     } | ||||
|  | @ -238,29 +227,23 @@ class UploadTest { | |||
|         dismissWarningDialog() | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
|                 .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<UploadMediaDetailAdapter.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<UploadMediaDetailAdapter.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 +251,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 +306,7 @@ class UploadTest { | |||
|             } catch (e: IOException) { | ||||
|                 e.printStackTrace() | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -345,8 +328,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 +337,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()) | ||||
|     } | ||||
| } | ||||
|  | @ -3,6 +3,7 @@ package fr.free.nrw.commons | |||
| import androidx.test.espresso.Espresso.onView | ||||
| import androidx.test.espresso.action.ViewActions | ||||
| import androidx.test.espresso.assertion.ViewAssertions.matches | ||||
| import androidx.test.espresso.matcher.ViewMatchers | ||||
| import androidx.test.espresso.matcher.ViewMatchers.isDisplayed | ||||
| import androidx.test.espresso.matcher.ViewMatchers.withId | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||
|  | @ -17,12 +18,11 @@ import org.junit.Before | |||
| import org.junit.Rule | ||||
| import org.junit.Test | ||||
| import org.junit.runner.RunWith | ||||
| import org.hamcrest.MatcherAssert.assertThat | ||||
| import org.hamcrest.CoreMatchers.equalTo | ||||
| 
 | ||||
| @LargeTest | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class WelcomeActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) | ||||
| 
 | ||||
|  | @ -61,7 +61,7 @@ class WelcomeActivityTest { | |||
|                 .perform(ViewActions.click()) | ||||
|             onView(withId(R.id.finishTutorialButton)) | ||||
|                 .perform(ViewActions.click()) | ||||
|             assertThat(activityRule.activity.isDestroyed, equalTo(true)) | ||||
|             assert(activityRule.activity.isDestroyed) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -71,10 +71,10 @@ class WelcomeActivityTest { | |||
|             .perform(ViewActions.click()) | ||||
|         onView(withId(R.id.welcomePager)) | ||||
|             .perform(ViewActions.swipeLeft()) | ||||
|         assertThat(true, equalTo(true)) | ||||
|         assert(true) | ||||
|         onView(withId(R.id.welcomePager)) | ||||
|             .perform(ViewActions.swipeRight()) | ||||
|         assertThat(true, equalTo(true)) | ||||
|         assert(true) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|  | @ -86,13 +86,13 @@ class WelcomeActivityTest { | |||
|             .perform(ViewActions.swipeLeft()) | ||||
|             .perform(ViewActions.swipeLeft()) | ||||
|             .perform(ViewActions.swipeLeft()) | ||||
|         assertThat(true, equalTo(true)) | ||||
|         assert(true) | ||||
|         onView(withId(R.id.welcomePager)) | ||||
|             .perform(ViewActions.swipeRight()) | ||||
|             .perform(ViewActions.swipeRight()) | ||||
|             .perform(ViewActions.swipeRight()) | ||||
|             .perform(ViewActions.swipeRight()) | ||||
|         assertThat(true, equalTo(true)) | ||||
|         assert(true) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|  | @ -103,10 +103,10 @@ class WelcomeActivityTest { | |||
|             if (viewPager.currentItem == 3) { | ||||
|                 onView(withId(R.id.welcomePager)) | ||||
|                     .perform(ViewActions.swipeLeft()) | ||||
|                 assertThat(true, equalTo(true)) | ||||
|                 assert(true) | ||||
|                 onView(withId(R.id.welcomePager)) | ||||
|                     .perform(ViewActions.swipeRight()) | ||||
|                 assertThat(true, equalTo(true)) | ||||
|                 assert(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | @ -121,7 +121,7 @@ class WelcomeActivityTest { | |||
|                     .perform(ViewActions.click()) | ||||
|                 onView(withId(R.id.finishTutorialButton)) | ||||
|                     .perform(ViewActions.click()) | ||||
|                 assertThat(activityRule.activity.isDestroyed, equalTo(true)) | ||||
|                 assert(activityRule.activity.isDestroyed) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  | @ -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() } | ||||
|     } | ||||
| } | ||||
|  | @ -11,24 +11,21 @@ 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!!) | ||||
|         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, | ||||
|             ) | ||||
|     fun extractFormattingAttributeSet(){ | ||||
|         val methodExtractFormattingAttribute = textView!!.javaClass.getDeclaredMethod( | ||||
|             "extractFormattingAttribute", Context::class.java, AttributeSet::class.java) | ||||
|         methodExtractFormattingAttribute.isAccessible = true | ||||
|         methodExtractFormattingAttribute.invoke(textView, context, null) | ||||
|     } | ||||
|  |  | |||
|  | @ -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() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| } | ||||
|  | @ -1,259 +1,239 @@ | |||
| <?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" /> | ||||
|     <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> | ||||
|     <uses-permission android:name="android.permission.READ_SYNC_STATS" /> | ||||
|     <uses-permission android:name="android.permission.REORDER_TASKS" /> | ||||
|     <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> | ||||
|     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> | ||||
|     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | ||||
|     <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /> | ||||
|     <uses-permission android:name="android.permission.GET_ACCOUNTS" /> | ||||
|     <uses-permission android:name="android.permission.USE_CREDENTIALS" /> | ||||
|     <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /> | ||||
|     <uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" /> | ||||
|     <uses-permission android:name="android.permission.SET_WALLPAPER"/> | ||||
|     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||
| 
 | ||||
|   <uses-permission android:name="android.permission.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" /> | ||||
| 
 | ||||
|   <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" | ||||
|         android:requestLegacyExternalStorage = "true" | ||||
|         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=".description.DescriptionEditActivity" | ||||
|             android:exported="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="org.acra.dialog.CrashReportDialog" | ||||
|             android:process=":acra" | ||||
|             android:launchMode="singleInstance" | ||||
|             android:excludeFromRecents="true" | ||||
|             android:finishOnTaskLaunch="true" /> | ||||
| 
 | ||||
|         <action android:name="android.intent.action.MAIN" /> | ||||
|       </intent-filter> | ||||
|         <activity | ||||
|             android:name=".media.ZoomableActivity" /> | ||||
| 
 | ||||
|       <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" /> | ||||
|         <activity android:name=".auth.LoginActivity"> | ||||
|             <intent-filter> | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
| 
 | ||||
|         <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|             </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.app.shortcuts" | ||||
|                 android:resource="@xml/shortcuts" /> | ||||
| 
 | ||||
|         <category android:name="android.intent.category.DEFAULT" /> | ||||
|         </activity> | ||||
|         <activity android:name=".WelcomeActivity" /> | ||||
| 
 | ||||
|         <data android:mimeType="image/*" /> | ||||
|         <data android:mimeType="audio/ogg" /> | ||||
|       </intent-filter> | ||||
|     </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" /> | ||||
|         <activity | ||||
|             android:hardwareAccelerated="false" | ||||
|             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" /> | ||||
| 
 | ||||
|     <service | ||||
|       android:name=".auth.WikiAccountAuthenticatorService" | ||||
|       android:exported="true" | ||||
|       android:process=":auth"> | ||||
|       <intent-filter> | ||||
|         <action android:name="android.accounts.AccountAuthenticator" /> | ||||
|       </intent-filter> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
| 
 | ||||
|       <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" /> | ||||
|                 <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" /> | ||||
| 
 | ||||
|     <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:configChanges="orientation|screenLayout|screenSize" | ||||
|             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=".customselector.ui.selector.CustomSelectorActivity" | ||||
|             android:label="@string/title_activity_custom_selector" | ||||
|             android:configChanges="screenSize|keyboard|orientation" | ||||
|             android:parentActivityName=".contributions.MainActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".category.CategoryDetailsActivity" | ||||
|             android:label="@string/title_activity_featured_images" | ||||
|             android:configChanges="screenSize|keyboard|orientation" | ||||
|             android:parentActivityName=".contributions.MainActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".explore.depictions.WikidataItemDetailsActivity" | ||||
|             android:label="@string/title_activity_featured_images" | ||||
|             android:configChanges="screenSize|keyboard|orientation" | ||||
|             android:parentActivityName=".contributions.MainActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".explore.SearchActivity" | ||||
|             android:label="@string/title_activity_search" | ||||
|             android:launchMode="singleTop" | ||||
|             android:configChanges="orientation|keyboardHidden|screenSize" | ||||
|             android:parentActivityName=".contributions.MainActivity" | ||||
|             /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".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" /> | ||||
| 
 | ||||
|         <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=".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.locations.BookmarkLocationsContentProvider" | ||||
|             android:authorities="${applicationId}.bookmarks.locations.contentprovider" | ||||
|             android:exported="false" | ||||
|             android:label="@string/provider_bookmarks_location" | ||||
|             android:syncable="false" /> | ||||
| 
 | ||||
|         <provider | ||||
|           android:name=".bookmarks.items.BookmarkItemsContentProvider" | ||||
|           android:authorities="${applicationId}.bookmarks.items.contentprovider" | ||||
|           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> | ||||
|  |  | |||
							
								
								
									
										177
									
								
								app/src/main/java/fr/free/nrw/commons/AboutActivity.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								app/src/main/java/fr/free/nrw/commons/AboutActivity.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,177 @@ | |||
| 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.view.Menu; | ||||
| import android.view.MenuInflater; | ||||
| import android.view.MenuItem; | ||||
| import android.view.View; | ||||
| import android.widget.ArrayAdapter; | ||||
| import android.widget.LinearLayout; | ||||
| import android.widget.Spinner; | ||||
| import androidx.annotation.NonNull; | ||||
| import fr.free.nrw.commons.databinding.ActivityAboutBinding; | ||||
| import fr.free.nrw.commons.theme.BaseActivity; | ||||
| import fr.free.nrw.commons.utils.ConfigUtils; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| 
 | ||||
| /** | ||||
|  * Represents about screen of this app | ||||
|  */ | ||||
| public class AboutActivity extends BaseActivity { | ||||
| 
 | ||||
|     /* | ||||
|       This View Binding class is auto-generated for each xml file. The format is usually the name | ||||
|       of the file with PascalCasing (The underscore characters will be ignored). | ||||
|       More information is available at https://developer.android.com/topic/libraries/view-binding | ||||
|      */ | ||||
|     private ActivityAboutBinding binding; | ||||
| 
 | ||||
|     /** | ||||
|      * This method helps in the creation About screen | ||||
|      * | ||||
|      * @param savedInstanceState Data bundle | ||||
|      */ | ||||
|     @Override | ||||
|     @SuppressLint("StringFormatInvalid") | ||||
|     public void onCreate(Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
| 
 | ||||
|         /* | ||||
|           Instead of just setting the view with the xml file. We need to use View Binding class. | ||||
|          */ | ||||
|         binding = ActivityAboutBinding.inflate(getLayoutInflater()); | ||||
|         final View view = binding.getRoot(); | ||||
|         setContentView(view); | ||||
| 
 | ||||
|         setSupportActionBar(binding.toolbarBinding.toolbar); | ||||
|         getSupportActionBar().setDisplayHomeAsUpEnabled(true); | ||||
|         final String aboutText = getString(R.string.about_license); | ||||
|         /* | ||||
|           We can then access all the views by just using the id names like this. | ||||
|           camelCasing is used with underscore characters being ignored. | ||||
|          */ | ||||
|         binding.aboutLicense.setHtmlText(aboutText); | ||||
| 
 | ||||
|         @SuppressLint("StringFormatMatches") | ||||
|         String improveText = String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL); | ||||
|         binding.aboutImprove.setHtmlText(improveText); | ||||
|         binding.aboutVersion.setText(ConfigUtils.getVersionNameWithSha(getApplicationContext())); | ||||
| 
 | ||||
|         Utils.setUnderlinedText(binding.aboutFaq, R.string.about_faq, getApplicationContext()); | ||||
|         Utils.setUnderlinedText(binding.aboutRateUs, R.string.about_rate_us, getApplicationContext()); | ||||
|         Utils.setUnderlinedText(binding.aboutUserGuide, R.string.user_guide, getApplicationContext()); | ||||
|         Utils.setUnderlinedText(binding.aboutPrivacyPolicy, R.string.about_privacy_policy, getApplicationContext()); | ||||
|         Utils.setUnderlinedText(binding.aboutTranslate, R.string.about_translate, getApplicationContext()); | ||||
|         Utils.setUnderlinedText(binding.aboutCredits, R.string.about_credits, getApplicationContext()); | ||||
| 
 | ||||
|         /* | ||||
|           To set listeners, we can create a separate method and use lambda syntax. | ||||
|         */ | ||||
|         binding.facebookLaunchIcon.setOnClickListener(this::launchFacebook); | ||||
|         binding.githubLaunchIcon.setOnClickListener(this::launchGithub); | ||||
|         binding.websiteLaunchIcon.setOnClickListener(this::launchWebsite); | ||||
|         binding.aboutRateUs.setOnClickListener(this::launchRatings); | ||||
|         binding.aboutCredits.setOnClickListener(this::launchCredits); | ||||
|         binding.aboutPrivacyPolicy.setOnClickListener(this::launchPrivacyPolicy); | ||||
|         binding.aboutUserGuide.setOnClickListener(this::launchUserGuide); | ||||
|         binding.aboutFaq.setOnClickListener(this::launchFrequentlyAskedQuesions); | ||||
|         binding.aboutTranslate.setOnClickListener(this::launchTranslate); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean onSupportNavigateUp() { | ||||
|         onBackPressed(); | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     public void launchFacebook(View view) { | ||||
|         Intent intent; | ||||
|         try { | ||||
|             intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL)); | ||||
|             intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME); | ||||
|             startActivity(intent); | ||||
|         } catch (Exception e) { | ||||
|             Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void launchGithub(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL)); | ||||
|     } | ||||
| 
 | ||||
|     public void launchWebsite(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL)); | ||||
|     } | ||||
| 
 | ||||
|     public void launchRatings(View view){ | ||||
|         Utils.rateApp(this); | ||||
|     } | ||||
| 
 | ||||
|     public void launchCredits(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL)); | ||||
|     } | ||||
| 
 | ||||
|     public void launchUserGuide(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL)); | ||||
|     } | ||||
| 
 | ||||
|     public void launchPrivacyPolicy(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)); | ||||
|     } | ||||
| 
 | ||||
|     public void launchFrequentlyAskedQuesions(View view) { | ||||
|         Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL)); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean onCreateOptionsMenu(Menu menu) { | ||||
|         MenuInflater inflater = getMenuInflater(); | ||||
|         inflater.inflate(R.menu.menu_about, menu); | ||||
|         return super.onCreateOptionsMenu(menu); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean onOptionsItemSelected(MenuItem item) { | ||||
|         switch (item.getItemId()) { | ||||
|             case R.id.share_app_icon: | ||||
|                 String shareText = String.format(getString(R.string.share_text), Urls.PLAY_STORE_URL_PREFIX + this.getPackageName()); | ||||
|                 Intent sendIntent = new Intent(); | ||||
|                 sendIntent.setAction(Intent.ACTION_SEND); | ||||
|                 sendIntent.putExtra(Intent.EXTRA_TEXT, shareText); | ||||
|                 sendIntent.setType("text/plain"); | ||||
|                 startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via))); | ||||
|                 return true; | ||||
|             default: | ||||
|                 return super.onOptionsItemSelected(item); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void launchTranslate(View view) { | ||||
|         @NonNull List<String> sortedLocalizedNamesRef = CommonsApplication.getInstance().getLanguageLookUpTable().getCanonicalNames(); | ||||
|         Collections.sort(sortedLocalizedNamesRef); | ||||
|         final ArrayAdapter<String> languageAdapter = new ArrayAdapter<>(AboutActivity.this, | ||||
|                 android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef); | ||||
|         final Spinner spinner = new Spinner(AboutActivity.this); | ||||
|         spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); | ||||
|         spinner.setAdapter(languageAdapter); | ||||
|         spinner.setGravity(17); | ||||
|         spinner.setPadding(50,0,0,0); | ||||
|         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(); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|             } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										18
									
								
								app/src/main/java/fr/free/nrw/commons/BasePresenter.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/src/main/java/fr/free/nrw/commons/BasePresenter.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| /** | ||||
|  * 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(@NonNull T view); | ||||
| 
 | ||||
|     /** | ||||
|      * Detaching a view makes sure that the view no more receives events from the presenter | ||||
|      */ | ||||
|     void onDetachView(); | ||||
| } | ||||
|  | @ -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() | ||||
| } | ||||
|  | @ -10,7 +10,6 @@ object BetaConstants { | |||
|      * 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 | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										86
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsAppAdapter.java
									
										
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										365
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										365
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,365 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| 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; | ||||
| 
 | ||||
| import android.annotation.SuppressLint; | ||||
| 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 androidx.multidex.BuildConfig; | ||||
| import androidx.multidex.MultiDexApplication; | ||||
| import com.facebook.drawee.backends.pipeline.Fresco; | ||||
| import com.facebook.imagepipeline.core.ImagePipeline; | ||||
| import com.facebook.imagepipeline.core.ImagePipelineConfig; | ||||
| import com.mapbox.mapboxsdk.Mapbox; | ||||
| import com.squareup.leakcanary.LeakCanary; | ||||
| import com.squareup.leakcanary.RefWatcher; | ||||
| import fr.free.nrw.commons.auth.SessionManager; | ||||
| import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao.Table; | ||||
| 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.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.logging.FileLoggingTree; | ||||
| import fr.free.nrw.commons.logging.LogUtils; | ||||
| import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher; | ||||
| import fr.free.nrw.commons.settings.Prefs; | ||||
| import fr.free.nrw.commons.upload.FileUtils; | ||||
| import fr.free.nrw.commons.utils.ConfigUtils; | ||||
| import 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 java.io.File; | ||||
| import java.util.HashMap; | ||||
| import java.util.HashSet; | ||||
| import java.util.Map; | ||||
| import java.util.Set; | ||||
| import javax.inject.Inject; | ||||
| import javax.inject.Named; | ||||
| 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 timber.log.Timber; | ||||
| 
 | ||||
| @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", | ||||
|     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 | ||||
| ) | ||||
| 
 | ||||
| public class CommonsApplication extends MultiDexApplication { | ||||
| 
 | ||||
|     public static final String IS_LIMITED_CONNECTION_MODE_ENABLED = "is_limited_connection_mode_enabled"; | ||||
|     @Inject | ||||
|     SessionManager sessionManager; | ||||
|     @Inject | ||||
|     DBOpenHelper dbOpenHelper; | ||||
| 
 | ||||
|     @Inject | ||||
|     @Named("default_preferences") | ||||
|     JsonKvStore defaultPrefs; | ||||
| 
 | ||||
|     @Inject | ||||
|     CustomOkHttpNetworkFetcher customOkHttpNetworkFetcher; | ||||
| 
 | ||||
|     /** | ||||
|      * 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 | ||||
|     ContributionDao contributionDao; | ||||
| 
 | ||||
|     /** | ||||
|      *  In-memory list of contributions whose uploads have been paused by the user | ||||
|      */ | ||||
|     public static Map<String, Boolean> pauseUploads = new HashMap<>(); | ||||
| 
 | ||||
|     /** | ||||
|      * 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) | ||||
|             .setNetworkFetcher(customOkHttpNetworkFetcher) | ||||
|             .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(); | ||||
|         //Delete stale logs if they have exceeded the specified size | ||||
|         deleteStaleLogs(logFileName, logDirectory); | ||||
| 
 | ||||
|         FileLoggingTree tree = new FileLoggingTree( | ||||
|             Log.VERBOSE, | ||||
|             logFileName, | ||||
|             logDirectory, | ||||
|             1000, | ||||
|             getFileLoggingThreadPool()); | ||||
| 
 | ||||
|         Timber.plant(tree); | ||||
|         Timber.plant(new Timber.DebugTree()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Deletes the logs zip file at the specified directory and file locations specified in the | ||||
|      * params | ||||
|      * | ||||
|      * @param logFileName | ||||
|      * @param logDirectory | ||||
|      */ | ||||
|     private void deleteStaleLogs(String logFileName, String logDirectory) { | ||||
|         try { | ||||
|             File file = new File(logDirectory + "/zip/" + logFileName + ".zip"); | ||||
|             if (file.exists() && file.getTotalSpace() > 1000000) {// In Kbs | ||||
|                 file.delete(); | ||||
|             } | ||||
|         } catch (Exception e) { | ||||
|             Timber.e(e); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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() | ||||
|             .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, Timber::e); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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 | ||||
| 
 | ||||
|         try { | ||||
|             contributionDao.deleteAll(); | ||||
|         } catch (SQLiteException e) { | ||||
|             Timber.e(e); | ||||
|         } | ||||
|         BookmarkPicturesDao.Table.onDelete(db); | ||||
|         BookmarkLocationsDao.Table.onDelete(db); | ||||
|         Table.onDelete(db); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Interface used to get log-out events | ||||
|      */ | ||||
|     public interface LogoutListener { | ||||
| 
 | ||||
|         void onLogoutComplete(); | ||||
|     } | ||||
| } | ||||
|  | @ -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) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										79
									
								
								app/src/main/java/fr/free/nrw/commons/License.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								app/src/main/java/fr/free/nrw/commons/License.java
									
										
									
									
									
										Normal 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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,65 @@ | |||
| package fr.free.nrw.commons.LocationPicker; | ||||
| 
 | ||||
| import android.app.Activity; | ||||
| import android.content.Intent; | ||||
| import com.mapbox.mapboxsdk.camera.CameraPosition; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class for starting the activity | ||||
|  */ | ||||
| public final class LocationPicker { | ||||
| 
 | ||||
|     /** | ||||
|      * Getting camera position from the intent using constants | ||||
|      * | ||||
|      * @param data intent | ||||
|      * @return CameraPosition | ||||
|      */ | ||||
|     public static CameraPosition getCameraPosition(final Intent data) { | ||||
|         return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); | ||||
|     } | ||||
| 
 | ||||
|     public static class IntentBuilder { | ||||
| 
 | ||||
|         private final Intent intent; | ||||
| 
 | ||||
|         /** | ||||
|          * Creates a new builder that creates an intent to launch the place picker activity. | ||||
|          */ | ||||
|         public IntentBuilder() { | ||||
|             intent = new Intent(); | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Gets and puts location in intent | ||||
|          * @param position CameraPosition | ||||
|          * @return LocationPicker.IntentBuilder | ||||
|          */ | ||||
|         public LocationPicker.IntentBuilder defaultLocation( | ||||
|             final CameraPosition position) { | ||||
|           intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position); | ||||
|           return this; | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Gets and puts activity name in intent | ||||
|          * @param activity activity key | ||||
|          * @return LocationPicker.IntentBuilder | ||||
|          */ | ||||
|         public LocationPicker.IntentBuilder activityKey( | ||||
|             final String activity) { | ||||
|           intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity); | ||||
|           return this; | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Gets and sets the activity | ||||
|          * @param activity Activity | ||||
|          * @return Intent | ||||
|          */ | ||||
|        public Intent build(final Activity activity) { | ||||
|           intent.setClass(activity, LocationPickerActivity.class); | ||||
|           return intent; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,450 @@ | |||
| package fr.free.nrw.commons.LocationPicker; | ||||
| 
 | ||||
| import static com.mapbox.mapboxsdk.style.layers.Property.NONE; | ||||
| import static com.mapbox.mapboxsdk.style.layers.Property.VISIBLE; | ||||
| import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconAllowOverlap; | ||||
| import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement; | ||||
| import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconImage; | ||||
| import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility; | ||||
| import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; | ||||
| import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM; | ||||
| 
 | ||||
| import android.content.Intent; | ||||
| import android.graphics.BitmapFactory; | ||||
| import android.os.Bundle; | ||||
| import android.text.Html; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.view.View; | ||||
| import android.view.Window; | ||||
| import android.view.animation.OvershootInterpolator; | ||||
| import android.widget.Button; | ||||
| import android.widget.ImageView; | ||||
| import android.widget.TextView; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.app.ActionBar; | ||||
| import androidx.appcompat.app.AppCompatActivity; | ||||
| import androidx.appcompat.widget.AppCompatTextView; | ||||
| import androidx.constraintlayout.widget.ConstraintLayout; | ||||
| import androidx.lifecycle.Observer; | ||||
| import androidx.lifecycle.ViewModelProvider; | ||||
| import com.google.android.material.floatingactionbutton.FloatingActionButton; | ||||
| import com.mapbox.android.core.permissions.PermissionsManager; | ||||
| import com.mapbox.geojson.Point; | ||||
| import com.mapbox.mapboxsdk.camera.CameraPosition; | ||||
| import com.mapbox.mapboxsdk.camera.CameraPosition.Builder; | ||||
| import com.mapbox.mapboxsdk.camera.CameraUpdateFactory; | ||||
| import com.mapbox.mapboxsdk.geometry.LatLng; | ||||
| import com.mapbox.mapboxsdk.location.LocationComponent; | ||||
| import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions; | ||||
| import com.mapbox.mapboxsdk.location.modes.CameraMode; | ||||
| import com.mapbox.mapboxsdk.location.modes.RenderMode; | ||||
| import com.mapbox.mapboxsdk.maps.MapView; | ||||
| import com.mapbox.mapboxsdk.maps.MapboxMap; | ||||
| import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraIdleListener; | ||||
| import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener; | ||||
| import com.mapbox.mapboxsdk.maps.OnMapReadyCallback; | ||||
| import com.mapbox.mapboxsdk.maps.Style; | ||||
| import com.mapbox.mapboxsdk.maps.UiSettings; | ||||
| import com.mapbox.mapboxsdk.style.layers.Layer; | ||||
| import com.mapbox.mapboxsdk.style.layers.SymbolLayer; | ||||
| import com.mapbox.mapboxsdk.style.sources.GeoJsonSource; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.Utils; | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; | ||||
| import fr.free.nrw.commons.theme.BaseActivity; | ||||
| import javax.inject.Inject; | ||||
| import javax.inject.Named; | ||||
| import org.jetbrains.annotations.NotNull; | ||||
| import timber.log.Timber; | ||||
| 
 | ||||
| /** | ||||
|  * Helps to pick location and return the result with an intent | ||||
|  */ | ||||
| public class LocationPickerActivity extends BaseActivity implements OnMapReadyCallback, | ||||
|     OnCameraMoveStartedListener, OnCameraIdleListener, Observer<CameraPosition> { | ||||
| 
 | ||||
|     /** | ||||
|      * DROPPED_MARKER_LAYER_ID : id for layer | ||||
|      */ | ||||
|     private static final String DROPPED_MARKER_LAYER_ID = "DROPPED_MARKER_LAYER_ID"; | ||||
|     /** | ||||
|      * cameraPosition : position of picker | ||||
|      */ | ||||
|     private CameraPosition cameraPosition; | ||||
|     /** | ||||
|      * markerImage : picker image | ||||
|      */ | ||||
|     private ImageView markerImage; | ||||
|     /** | ||||
|      * mapboxMap : map | ||||
|      */ | ||||
|     private MapboxMap mapboxMap; | ||||
|     /** | ||||
|      * mapView : view of the map | ||||
|      */ | ||||
|     private MapView mapView; | ||||
|     /** | ||||
|      * tvAttribution : credit | ||||
|      */ | ||||
|     private AppCompatTextView tvAttribution; | ||||
|     /** | ||||
|      * activity : activity key | ||||
|      */ | ||||
|     private String activity; | ||||
|     /** | ||||
|      * modifyLocationButton : button for start editing location | ||||
|      */ | ||||
|     Button modifyLocationButton; | ||||
|     /** | ||||
|      * showInMapButton : button for showing in map | ||||
|      */ | ||||
|     TextView showInMapButton; | ||||
|     /** | ||||
|      * placeSelectedButton : fab for selecting location | ||||
|      */ | ||||
|     FloatingActionButton placeSelectedButton; | ||||
|     /** | ||||
|      * droppedMarkerLayer : Layer for static screen | ||||
|      */ | ||||
|     private Layer droppedMarkerLayer; | ||||
|     /** | ||||
|      * shadow : imageview of shadow | ||||
|      */ | ||||
|     private ImageView shadow; | ||||
|     /** | ||||
|      * largeToolbarText : textView of shadow | ||||
|      */ | ||||
|     private TextView largeToolbarText; | ||||
|     /** | ||||
|      * smallToolbarText : textView of shadow | ||||
|      */ | ||||
|     private TextView smallToolbarText; | ||||
|     /** | ||||
|      * applicationKvStore : for storing values | ||||
|      */ | ||||
|     @Inject | ||||
|     @Named("default_preferences") | ||||
|     public | ||||
|     JsonKvStore applicationKvStore; | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onCreate(@Nullable final Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
| 
 | ||||
|         getWindow().requestFeature(Window.FEATURE_ACTION_BAR); | ||||
|         final ActionBar actionBar = getSupportActionBar(); | ||||
|         if (actionBar != null) { | ||||
|             actionBar.hide(); | ||||
|         } | ||||
|         setContentView(R.layout.activity_location_picker); | ||||
| 
 | ||||
|         if (savedInstanceState == null) { | ||||
|             cameraPosition = getIntent() | ||||
|                 .getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); | ||||
|             activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY); | ||||
|         } | ||||
| 
 | ||||
|         final LocationPickerViewModel viewModel = new ViewModelProvider(this) | ||||
|             .get(LocationPickerViewModel.class); | ||||
|         viewModel.getResult().observe(this, this); | ||||
| 
 | ||||
|         bindViews(); | ||||
|         addBackButtonListener(); | ||||
|         addPlaceSelectedButton(); | ||||
|         addCredits(); | ||||
|         getToolbarUI(); | ||||
| 
 | ||||
|         if (activity.equals("UploadActivity")) { | ||||
|             placeSelectedButton.setVisibility(View.GONE); | ||||
|             modifyLocationButton.setVisibility(View.VISIBLE); | ||||
|             showInMapButton.setVisibility(View.VISIBLE); | ||||
|             largeToolbarText.setText(getResources().getString(R.string.image_location)); | ||||
|             smallToolbarText.setText(getResources(). | ||||
|                 getString(R.string.check_whether_location_is_correct)); | ||||
|         } | ||||
| 
 | ||||
|         mapView.onCreate(savedInstanceState); | ||||
|         mapView.getMapAsync(this); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * For showing credits | ||||
|      */ | ||||
|     private void addCredits() { | ||||
|         tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); | ||||
|         tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clicking back button destroy locationPickerActivity | ||||
|      */ | ||||
|     private void addBackButtonListener() { | ||||
|         final ImageView backButton = findViewById(R.id.mapbox_place_picker_toolbar_back_button); | ||||
|         backButton.setOnClickListener(view -> finish()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Binds mapView and location picker icon | ||||
|      */ | ||||
|     private void bindViews() { | ||||
|         mapView = findViewById(R.id.map_view); | ||||
|         markerImage = findViewById(R.id.location_picker_image_view_marker); | ||||
|         tvAttribution = findViewById(R.id.tv_attribution); | ||||
|         modifyLocationButton = findViewById(R.id.modify_location); | ||||
|         showInMapButton = findViewById(R.id.show_in_map); | ||||
|         showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase()); | ||||
|         shadow = findViewById(R.id.location_picker_image_view_shadow); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Binds the listeners | ||||
|      */ | ||||
|     private void bindListeners() { | ||||
|         mapboxMap.addOnCameraMoveStartedListener( | ||||
|             this); | ||||
|         mapboxMap.addOnCameraIdleListener( | ||||
|             this); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets toolbar color | ||||
|      */ | ||||
|     private void getToolbarUI() { | ||||
|         final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar); | ||||
|         largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view); | ||||
|         smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view); | ||||
|         toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Takes action when map is ready to show | ||||
|      * @param mapboxMap map | ||||
|      */ | ||||
|     @Override | ||||
|     public void onMapReady(final MapboxMap mapboxMap) { | ||||
|         this.mapboxMap = mapboxMap; | ||||
|         mapboxMap.setStyle(Style.MAPBOX_STREETS, this::onStyleLoaded); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initializes dropped marker and layer | ||||
|      * Handles camera position based on options | ||||
|      * Enables location components | ||||
|      * | ||||
|      * @param style style | ||||
|      */ | ||||
|     private void onStyleLoaded(final Style style) { | ||||
|         if (modifyLocationButton.getVisibility() == View.VISIBLE) { | ||||
|             initDroppedMarker(style); | ||||
|             adjustCameraBasedOnOptions(); | ||||
|             enableLocationComponent(style); | ||||
|             if (style.getLayer(DROPPED_MARKER_LAYER_ID) != null) { | ||||
|                 final GeoJsonSource source = style.getSourceAs("dropped-marker-source-id"); | ||||
|                 if (source != null) { | ||||
|                     source.setGeoJson(Point.fromLngLat(cameraPosition.target.getLongitude(), | ||||
|                         cameraPosition.target.getLatitude())); | ||||
|                 } | ||||
|                 droppedMarkerLayer = style.getLayer(DROPPED_MARKER_LAYER_ID); | ||||
|                 if (droppedMarkerLayer != null) { | ||||
|                     droppedMarkerLayer.setProperties(visibility(VISIBLE)); | ||||
|                     markerImage.setVisibility(View.GONE); | ||||
|                     shadow.setVisibility(View.GONE); | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             adjustCameraBasedOnOptions(); | ||||
|             enableLocationComponent(style); | ||||
|             bindListeners(); | ||||
|         } | ||||
| 
 | ||||
|         modifyLocationButton.setOnClickListener(v -> onClickModifyLocation()); | ||||
|         showInMapButton.setOnClickListener(v -> showInMap()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Handles onclick event of modifyLocationButton | ||||
|      */ | ||||
|     private void onClickModifyLocation() { | ||||
|         placeSelectedButton.setVisibility(View.VISIBLE); | ||||
|         modifyLocationButton.setVisibility(View.GONE); | ||||
|         showInMapButton.setVisibility(View.GONE); | ||||
|         droppedMarkerLayer.setProperties(visibility(NONE)); | ||||
|         markerImage.setVisibility(View.VISIBLE); | ||||
|         shadow.setVisibility(View.VISIBLE); | ||||
|         largeToolbarText.setText(getResources().getString(R.string.choose_a_location)); | ||||
|         smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust)); | ||||
|         bindListeners(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show the location in map app | ||||
|      */ | ||||
|     public void showInMap(){ | ||||
|         Utils.handleGeoCoordinates(this, | ||||
|             new fr.free.nrw.commons.location.LatLng(cameraPosition.target.getLatitude(), | ||||
|                 cameraPosition.target.getLongitude(), 0.0f)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize Dropped Marker and layer without showing | ||||
|      * @param loadedMapStyle style | ||||
|      */ | ||||
|     private void initDroppedMarker(@NonNull final Style loadedMapStyle) { | ||||
|         // Add the marker image to map | ||||
|         loadedMapStyle.addImage("dropped-icon-image", BitmapFactory.decodeResource( | ||||
|             getResources(), R.drawable.map_default_map_marker)); | ||||
|         loadedMapStyle.addSource(new GeoJsonSource("dropped-marker-source-id")); | ||||
|         loadedMapStyle.addLayer(new SymbolLayer(DROPPED_MARKER_LAYER_ID, | ||||
|             "dropped-marker-source-id").withProperties( | ||||
|             iconImage("dropped-icon-image"), | ||||
|             visibility(NONE), | ||||
|             iconAllowOverlap(true), | ||||
|             iconIgnorePlacement(true) | ||||
|         )); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * move the location to the current media coordinates | ||||
|      */ | ||||
|     private void adjustCameraBasedOnOptions() { | ||||
|         mapboxMap.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Enables location components | ||||
|      * @param loadedMapStyle Style | ||||
|      */ | ||||
|     @SuppressWarnings( {"MissingPermission"}) | ||||
|     private void enableLocationComponent(@NonNull final Style loadedMapStyle) { | ||||
|         final UiSettings uiSettings = mapboxMap.getUiSettings(); | ||||
|         uiSettings.setAttributionEnabled(false); | ||||
| 
 | ||||
|         // Check if permissions are enabled and if not request | ||||
|         if (PermissionsManager.areLocationPermissionsGranted(this)) { | ||||
| 
 | ||||
|             // Get an instance of the component | ||||
|             final LocationComponent locationComponent = mapboxMap.getLocationComponent(); | ||||
| 
 | ||||
|             // Activate with options | ||||
|             locationComponent.activateLocationComponent( | ||||
|                 LocationComponentActivationOptions.builder(this, loadedMapStyle).build()); | ||||
| 
 | ||||
|             // Enable to make component visible | ||||
|             locationComponent.setLocationComponentEnabled(true); | ||||
| 
 | ||||
|             // Set the component's camera mode | ||||
|             locationComponent.setCameraMode(CameraMode.NONE); | ||||
| 
 | ||||
|             // Set the component's render mode | ||||
|             locationComponent.setRenderMode(RenderMode.NORMAL); | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Acts on camera moving | ||||
|      * @param reason int | ||||
|      */ | ||||
|     @Override | ||||
|     public void onCameraMoveStarted(final int reason) { | ||||
|         Timber.v("Map camera has begun moving."); | ||||
|         if (markerImage.getTranslationY() == 0) { | ||||
|             markerImage.animate().translationY(-75) | ||||
|                 .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Acts on camera idle | ||||
|      */ | ||||
|     @Override | ||||
|     public void onCameraIdle() { | ||||
|         Timber.v("Map camera is now idling."); | ||||
|         markerImage.animate().translationY(0) | ||||
|             .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Takes action on camera position | ||||
|      * @param position position of picker | ||||
|      */ | ||||
|     @Override | ||||
|     public void onChanged(@Nullable CameraPosition position) { | ||||
|         if (position == null) { | ||||
|             position = new Builder() | ||||
|                 .target(new LatLng(mapboxMap.getCameraPosition().target.getLatitude(), | ||||
|                     mapboxMap.getCameraPosition().target.getLongitude())) | ||||
|                 .zoom(16).build(); | ||||
|         } | ||||
|         cameraPosition = position; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select the preferable location | ||||
|      */ | ||||
|     private void addPlaceSelectedButton() { | ||||
|         placeSelectedButton = findViewById(R.id.location_chosen_button); | ||||
|         placeSelectedButton.setOnClickListener(view -> placeSelected()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return the intent with required data | ||||
|      */ | ||||
|     void placeSelected() { | ||||
|         if (activity.equals("NoLocationUploadActivity")) { | ||||
|             applicationKvStore.putString(LAST_LOCATION, | ||||
|                 mapboxMap.getCameraPosition().target.getLatitude() | ||||
|                     + "," | ||||
|                     + mapboxMap.getCameraPosition().target.getLongitude()); | ||||
|             applicationKvStore.putString(LAST_ZOOM, mapboxMap.getCameraPosition().zoom + ""); | ||||
|         } | ||||
|         final Intent returningIntent = new Intent(); | ||||
|         returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, | ||||
|             mapboxMap.getCameraPosition()); | ||||
|         setResult(AppCompatActivity.RESULT_OK, returningIntent); | ||||
|         finish(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onStart() { | ||||
|         super.onStart(); | ||||
|         mapView.onStart(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onResume() { | ||||
|         super.onResume(); | ||||
|         mapView.onResume(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onPause() { | ||||
|         super.onPause(); | ||||
|         mapView.onPause(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onStop() { | ||||
|         super.onStop(); | ||||
|         mapView.onStop(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onSaveInstanceState(final @NotNull Bundle outState) { | ||||
|         super.onSaveInstanceState(outState); | ||||
|         mapView.onSaveInstanceState(outState); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onDestroy() { | ||||
|         super.onDestroy(); | ||||
|         mapView.onDestroy(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onLowMemory() { | ||||
|         super.onLowMemory(); | ||||
|         mapView.onLowMemory(); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,17 @@ | |||
| package fr.free.nrw.commons.LocationPicker; | ||||
| 
 | ||||
| /** | ||||
|  * Constants need for location picking | ||||
|  */ | ||||
| public final class LocationPickerConstants { | ||||
| 
 | ||||
|     public static final String ACTIVITY_KEY | ||||
|         = "location.picker.activity"; | ||||
| 
 | ||||
|     public static final String MAP_CAMERA_POSITION | ||||
|         = "location.picker.cameraPosition"; | ||||
| 
 | ||||
| 
 | ||||
|     private LocationPickerConstants() { | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,63 @@ | |||
| package fr.free.nrw.commons.LocationPicker; | ||||
| 
 | ||||
| import android.app.Application; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.lifecycle.AndroidViewModel; | ||||
| import androidx.lifecycle.MutableLiveData; | ||||
| import com.mapbox.mapboxsdk.camera.CameraPosition; | ||||
| import org.jetbrains.annotations.NotNull; | ||||
| import retrofit2.Call; | ||||
| import retrofit2.Callback; | ||||
| import retrofit2.Response; | ||||
| import timber.log.Timber; | ||||
| 
 | ||||
| /** | ||||
|  * Observes live camera position data | ||||
|  */ | ||||
| public class LocationPickerViewModel extends AndroidViewModel implements Callback<CameraPosition> { | ||||
| 
 | ||||
|     /** | ||||
|      * Wrapping CameraPosition with MutableLiveData | ||||
|      */ | ||||
|     private final MutableLiveData<CameraPosition> result = new MutableLiveData<>(); | ||||
| 
 | ||||
|     /** | ||||
|      * Constructor for this class | ||||
|      * | ||||
|      * @param application Application | ||||
|      */ | ||||
|     public LocationPickerViewModel(@NonNull final Application application) { | ||||
|         super(application); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Responses on camera position changing | ||||
|      * | ||||
|      * @param call     Call<CameraPosition> | ||||
|      * @param response Response<CameraPosition> | ||||
|      */ | ||||
|     @Override | ||||
|     public void onResponse(final @NotNull Call<CameraPosition> call, | ||||
|         final Response<CameraPosition> response) { | ||||
|         if (response.body() == null) { | ||||
|             result.setValue(null); | ||||
|             return; | ||||
|         } | ||||
|         result.setValue(response.body()); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onFailure(final @NotNull Call<CameraPosition> call, final @NotNull Throwable t) { | ||||
|         Timber.e(t); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets live CameraPosition | ||||
|      * | ||||
|      * @return MutableLiveData<CameraPosition> | ||||
|      */ | ||||
|     public MutableLiveData<CameraPosition> getResult() { | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										30
									
								
								app/src/main/java/fr/free/nrw/commons/MapController.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/src/main/java/fr/free/nrw/commons/MapController.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| import fr.free.nrw.commons.location.LatLng; | ||||
| import fr.free.nrw.commons.nearby.Place; | ||||
| import java.util.List; | ||||
| 
 | ||||
| public abstract class MapController { | ||||
| 
 | ||||
|     /** | ||||
|      * We pass this variable as a group of placeList and boundaryCoordinates | ||||
|      */ | ||||
|     public class NearbyPlacesInfo { | ||||
|         public List<Place> placeList; // List of nearby places | ||||
|         public LatLng[] boundaryCoordinates; // Corners of nearby area | ||||
|         public LatLng curLatLng; // Current location when this places are populated | ||||
|         public LatLng searchLatLng; // Search location for finding this places | ||||
|         public List<Media> mediaList; // Search location for finding this places | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * We pass this variable as a group of placeList and boundaryCoordinates | ||||
|      */ | ||||
|     public class ExplorePlacesInfo { | ||||
|         public List<Place> explorePlaceList; // List of nearby places | ||||
|         public LatLng[] boundaryCoordinates; // Corners of nearby area | ||||
|         public LatLng curLatLng; // Current location when this places are populated | ||||
|         public LatLng searchLatLng; // Search location for finding this places | ||||
|         public List<Media> mediaList; // Search location for finding this places | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -1,15 +1,11 @@ | |||
| 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 | ||||
| import kotlinx.android.parcel.Parcelize | ||||
| import org.wikipedia.dataclient.mwapi.MwQueryPage | ||||
| import org.wikipedia.page.PageTitle | ||||
| import java.util.* | ||||
| 
 | ||||
| @Parcelize | ||||
| class Media constructor( | ||||
|  | @ -19,6 +15,7 @@ class Media constructor( | |||
|      */ | ||||
|     var pageId: String = UUID.randomUUID().toString(), | ||||
|     var thumbUrl: String? = null, | ||||
| 
 | ||||
|     /** | ||||
|      * Gets image URL | ||||
|      * @return Image URL | ||||
|  | @ -30,9 +27,16 @@ class Media constructor( | |||
|      */ | ||||
|     var filename: String? = null, | ||||
|     /** | ||||
|      * The fallback description of the file, used if no other description is provided. | ||||
|      * Gets the file description. | ||||
|      * @return file description as a string | ||||
|      */ | ||||
|     // monolingual description on input... | ||||
|     /** | ||||
|      * Sets the file description. | ||||
|      * @param fallbackDescription the new description of the file | ||||
|      */ | ||||
|     var fallbackDescription: String? = null, | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the upload date of the file. | ||||
|      * Can be null. | ||||
|  | @ -40,25 +44,28 @@ class Media constructor( | |||
|      */ | ||||
|     var dateUploaded: Date? = null, | ||||
|     /** | ||||
|      * The license name of the file. | ||||
|      * Gets the license name of the file. | ||||
|      * @return license as a String | ||||
|      */ | ||||
|     /** | ||||
|      * Sets the license name of the file. | ||||
|      * | ||||
|      * @param license license name as a String | ||||
|      */ | ||||
|     var license: String? = null, | ||||
|     /** | ||||
|      * The URL corresponding to the license. | ||||
|      */ | ||||
|     var licenseUrl: String? = null, | ||||
|     /** | ||||
|      * The name of the creator of the file. | ||||
|      * Gets the name of the creator of the file. | ||||
|      * @return author name as a String | ||||
|      */ | ||||
|     /** | ||||
|      * Sets the author name of the file. | ||||
|      * @param author creator name as a string | ||||
|      */ | ||||
|     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, | ||||
| 
 | ||||
|     var user:String?=null, | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the categories the file falls under. | ||||
|      * @return file categories as an ArrayList of Strings | ||||
|  | @ -72,111 +79,46 @@ class Media constructor( | |||
|     var captions: Map<String, String> = emptyMap(), | ||||
|     var descriptions: Map<String, String> = emptyMap(), | ||||
|     var depictionIds: List<String> = emptyList(), | ||||
|     var creatorIds: List<String> = emptyList(), | ||||
|     /** | ||||
|      * This field was added to find non-hidden categories | ||||
|      * Stores the mapping of category title to hidden attribute | ||||
|      * Example: "Mountains" => false, "CC-BY-SA-2.0" => true | ||||
|      */ | ||||
|     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(), | ||||
|     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap() | ||||
| ) : Parcelable { | ||||
| 
 | ||||
|     constructor( | ||||
|         captions: Map<String, String>, | ||||
|         categories: List<String>?, | ||||
|         filename: String?, | ||||
|         fallbackDescription: String?, | ||||
|         author: String?, | ||||
|         user: String?, | ||||
|         author: String?, user:String? | ||||
|     ) : this( | ||||
|         filename = filename, | ||||
|         fallbackDescription = fallbackDescription, | ||||
|         dateUploaded = Date(), | ||||
|         author = author, | ||||
|         user = user, | ||||
|         user=user, | ||||
|         categories = categories, | ||||
|         captions = captions, | ||||
|         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) { | ||||
|             if (filename != null) | ||||
|                 pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") | ||||
|             } else { | ||||
|             else | ||||
|                 "" | ||||
|             } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets file page title | ||||
|      * @return New media page title | ||||
|      */ | ||||
|     val pageTitle: PageTitle | ||||
|         get() = PageTitle(filename!!, WikiSite(COMMONS_URL)) | ||||
|     val pageTitle: PageTitle get() = Utils.getPageTitle(filename!!) | ||||
| 
 | ||||
|     /** | ||||
|      * Returns wikicode to use the media file on a MediaWiki site | ||||
|  | @ -186,21 +128,17 @@ class Media constructor( | |||
|         get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) | ||||
| 
 | ||||
|     val mostRelevantCaption: String | ||||
|         get() = | ||||
|             captions[Locale.getDefault().language] | ||||
|                 ?: captions.values.firstOrNull() | ||||
|                 ?: displayTitle | ||||
|         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 | ||||
|         get() = field                     // getter | ||||
|         set(value) { field = value }      // setter | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| 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 fr.free.nrw.commons.media.IdAndCaptions | ||||
| import fr.free.nrw.commons.media.MediaClient | ||||
| import io.reactivex.Single | ||||
| import timber.log.Timber | ||||
| import javax.inject.Inject | ||||
|  | @ -17,56 +17,42 @@ import javax.inject.Singleton | |||
|  * 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() } | ||||
| class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) { | ||||
| 
 | ||||
|         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 fetchDepictionIdsAndLabels(media: Media) = | ||||
|         mediaClient.getEntities(media.depictionIds) | ||||
|             .map { | ||||
|                 it.entities() | ||||
|                     .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } | ||||
|             } | ||||
|             .map { it.map { (key, value) -> IdAndCaptions(key, value) } } | ||||
|             .onErrorReturn { emptyList() } | ||||
| 
 | ||||
|         fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) | ||||
|     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 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 refresh(media: Media): Single<Media> { | ||||
|         return Single.ambArray( | ||||
|             mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId) | ||||
|                 .onErrorResumeNext { Single.never() }, | ||||
|             mediaClient.getMedia(media.filename) | ||||
|                 .onErrorResumeNext { Single.never() } | ||||
|         ) | ||||
| 
 | ||||
|         fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title) | ||||
| 
 | ||||
|         /** | ||||
|          * Fetches wikitext from mediaClient | ||||
|          */ | ||||
|         fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title) | ||||
|     } | ||||
| 
 | ||||
|     fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title); | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches wikitext from mediaClient | ||||
|      */ | ||||
|     fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										8
									
								
								app/src/main/java/fr/free/nrw/commons/MvpView.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/src/main/java/fr/free/nrw/commons/MvpView.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| /** | ||||
|  * Base interface for all the views | ||||
|  */ | ||||
| public interface MvpView { | ||||
|     void showMessage(String message); | ||||
| } | ||||
|  | @ -0,0 +1,114 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import okhttp3.Cache; | ||||
| import okhttp3.Interceptor; | ||||
| import okhttp3.OkHttpClient; | ||||
| import okhttp3.Request; | ||||
| import okhttp3.Response; | ||||
| import okhttp3.ResponseBody; | ||||
| import okhttp3.logging.HttpLoggingInterceptor; | ||||
| import okhttp3.logging.HttpLoggingInterceptor.Level; | ||||
| import org.wikipedia.dataclient.SharedPreferenceCookieManager; | ||||
| import org.wikipedia.dataclient.okhttp.HttpStatusException; | ||||
| import timber.log.Timber; | ||||
| 
 | ||||
| public final class OkHttpConnectionFactory { | ||||
|     private static final String CACHE_DIR_NAME = "okhttp-cache"; | ||||
|     private static final long NET_CACHE_SIZE = 64 * 1024 * 1024; | ||||
|     @NonNull private static final Cache NET_CACHE = new Cache(new File(CommonsApplication.getInstance().getCacheDir(), | ||||
|             CACHE_DIR_NAME), NET_CACHE_SIZE); | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static final OkHttpClient CLIENT = createClient(); | ||||
| 
 | ||||
|     @NonNull public static OkHttpClient getClient() { | ||||
|         return CLIENT; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static OkHttpClient createClient() { | ||||
|         return new OkHttpClient.Builder() | ||||
|                 .cookieJar(SharedPreferenceCookieManager.getInstance()) | ||||
|                 .cache(NET_CACHE) | ||||
|                 .connectTimeout(120, TimeUnit.SECONDS) | ||||
|                 .writeTimeout(120, TimeUnit.SECONDS) | ||||
|                 .readTimeout(120, TimeUnit.SECONDS) | ||||
|                 .addInterceptor(getLoggingInterceptor()) | ||||
|                 .addInterceptor(new UnsuccessfulResponseInterceptor()) | ||||
|                 .addInterceptor(new CommonHeaderRequestInterceptor()) | ||||
|                 .build(); | ||||
|     } | ||||
| 
 | ||||
|     private static HttpLoggingInterceptor getLoggingInterceptor() { | ||||
|         final HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor() | ||||
|             .setLevel(Level.BASIC); | ||||
| 
 | ||||
|         httpLoggingInterceptor.redactHeader("Authorization"); | ||||
|         httpLoggingInterceptor.redactHeader("Cookie"); | ||||
| 
 | ||||
|         return httpLoggingInterceptor; | ||||
|     } | ||||
| 
 | ||||
|     private static class CommonHeaderRequestInterceptor implements Interceptor { | ||||
| 
 | ||||
|         @Override | ||||
|         @NonNull | ||||
|         public Response intercept(@NonNull final Chain chain) throws IOException { | ||||
|             final Request request = chain.request().newBuilder() | ||||
|                     .header("User-Agent", CommonsApplication.getInstance().getUserAgent()) | ||||
|                     .build(); | ||||
|             return chain.proceed(request); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static class UnsuccessfulResponseInterceptor implements Interceptor { | ||||
|         private static final List<String> DO_NOT_INTERCEPT = Collections.singletonList( | ||||
|             "api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1"); | ||||
| 
 | ||||
|         private static final String ERRORS_PREFIX = "{\"error"; | ||||
| 
 | ||||
|         @Override | ||||
|         @NonNull | ||||
|         public Response intercept(@NonNull final Chain chain) throws IOException { | ||||
|             final Response rsp = chain.proceed(chain.request()); | ||||
| 
 | ||||
|             // Do not intercept certain requests and let the caller handle the errors | ||||
|             if(isExcludedUrl(chain.request())) { | ||||
|                 return rsp; | ||||
|             } | ||||
|             if (rsp.isSuccessful()) { | ||||
|                 try (final ResponseBody responseBody = rsp.peekBody(ERRORS_PREFIX.length())) { | ||||
|                     if (ERRORS_PREFIX.equals(responseBody.string())) { | ||||
|                         try (final ResponseBody body = rsp.body()) { | ||||
|                             throw new IOException(body.string()); | ||||
|                         } | ||||
|                     } | ||||
|                 } catch (final IOException e) { | ||||
|                     Timber.e(e); | ||||
|                 } | ||||
|                 return rsp; | ||||
|             } | ||||
|             throw new HttpStatusException(rsp); | ||||
|         } | ||||
| 
 | ||||
|         private boolean isExcludedUrl(final Request request) { | ||||
|             final String requestUrl = request.url().toString(); | ||||
|             for(final String url: DO_NOT_INTERCEPT) { | ||||
|                 if(requestUrl.contains(url)) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private OkHttpConnectionFactory() { | ||||
|     } | ||||
| } | ||||
|  | @ -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" | ||||
|     } | ||||
| } | ||||
|  | @ -3,16 +3,13 @@ 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" | ||||
|  |  | |||
							
								
								
									
										246
									
								
								app/src/main/java/fr/free/nrw/commons/Utils.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								app/src/main/java/fr/free/nrw/commons/Utils.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,246 @@ | |||
| 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.CustomTabColorSchemeParams; | ||||
| import androidx.browser.customtabs.CustomTabsIntent; | ||||
| import androidx.core.content.ContextCompat; | ||||
| 
 | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; | ||||
| import java.util.Date; | ||||
| 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; | ||||
| import static fr.free.nrw.commons.campaigns.CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE; | ||||
| 
 | ||||
| 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; | ||||
|         } | ||||
| 
 | ||||
|         final CustomTabColorSchemeParams color = new CustomTabColorSchemeParams.Builder() | ||||
|             .setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor)) | ||||
|             .setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.primaryDarkColor)) | ||||
|             .build(); | ||||
| 
 | ||||
|         CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); | ||||
|         builder.setDefaultColorSchemeParams(color); | ||||
|         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 drawingCache = screenView.getDrawingCache(); | ||||
|         if (drawingCache != null) { | ||||
|             Bitmap bitmap = Bitmap.createBitmap(drawingCache); | ||||
|             screenView.setDrawingCacheEnabled(false); | ||||
|             return bitmap; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|     *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); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * For now we are enabling the monuments only when the date lies between 1 Sept & 31 OCt | ||||
|      * @param date | ||||
|      * @return | ||||
|      */ | ||||
|     public static boolean isMonumentsEnabled(final Date date) { | ||||
|         if (date.getMonth() == 8) { | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Util function to get the start date of wlm monument | ||||
|      * For this release we are hardcoding it to be 1st September | ||||
|      * @return | ||||
|      */ | ||||
|     public static String getWLMStartDate() { | ||||
|         return "1 Sep"; | ||||
|     } | ||||
| 
 | ||||
|     /*** | ||||
|      * Util function to get the end date of wlm monument | ||||
|      * For this release we are hardcoding it to be 31st October | ||||
|      * @return | ||||
|      */ | ||||
|     public static String getWLMEndDate() { | ||||
|         return "30 Sep"; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										7
									
								
								app/src/main/java/fr/free/nrw/commons/ViewHolder.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/src/main/java/fr/free/nrw/commons/ViewHolder.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| import android.content.Context; | ||||
| 
 | ||||
| public interface ViewHolder<T> { | ||||
|     void bindModel(Context context, T model); | ||||
| } | ||||
							
								
								
									
										57
									
								
								app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								app/src/main/java/fr/free/nrw/commons/ViewPagerAdapter.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| package fr.free.nrw.commons; | ||||
| 
 | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| import androidx.fragment.app.FragmentPagerAdapter; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| 
 | ||||
| /** | ||||
|  * This adapter will be used to display fragments in a ViewPager | ||||
|  */ | ||||
| public class ViewPagerAdapter extends FragmentPagerAdapter { | ||||
|     private List<Fragment> fragmentList = new ArrayList<>(); | ||||
|     private List<String> fragmentTitleList = new ArrayList<>(); | ||||
| 
 | ||||
|     public ViewPagerAdapter(FragmentManager manager) { | ||||
|         super(manager); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method returns the fragment of the viewpager at a particular position | ||||
|      * @param position | ||||
|      */ | ||||
|     @Override | ||||
|     public Fragment getItem(int position) { | ||||
|         return fragmentList.get(position); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method returns the total number of fragments in the viewpager. | ||||
|      * @return size | ||||
|      */ | ||||
|     @Override | ||||
|     public int getCount() { | ||||
|         return fragmentList.size(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method sets the fragment and title list in the viewpager | ||||
|      * @param fragmentList List of all fragments to be displayed in the viewpager | ||||
|      * @param fragmentTitleList List of all titles of the fragments | ||||
|      */ | ||||
|     public void setTabData(List<Fragment> fragmentList, List<String> fragmentTitleList) { | ||||
|         this.fragmentList = fragmentList; | ||||
|         this.fragmentTitleList = fragmentTitleList; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method returns the title of the page at a particular position | ||||
|      * @param position | ||||
|      */ | ||||
|     @Override | ||||
|     public CharSequence getPageTitle(int position) { | ||||
|         return fragmentTitleList.get(position); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										116
									
								
								app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,116 @@ | |||
| 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; | ||||
| import android.app.AlertDialog; | ||||
| import android.widget.Button; | ||||
| 
 | ||||
| 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; | ||||
|     private AlertDialog.Builder dialogBuilder; | ||||
|     private AlertDialog dialog; | ||||
|     Button okButton; | ||||
| 
 | ||||
|     /** | ||||
|      * 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); | ||||
| 
 | ||||
|             dialogBuilder = new AlertDialog.Builder(this); | ||||
|             final View contactPopupView = getLayoutInflater().inflate(R.layout.popup_for_copyright,null); | ||||
|             dialogBuilder.setView(contactPopupView); | ||||
|             dialog = dialogBuilder.create(); | ||||
|             dialog.show(); | ||||
| 
 | ||||
|             okButton = dialog.findViewById(R.id.button_ok); | ||||
|             okButton.setOnClickListener(view -> dialog.dismiss()); | ||||
|         } | ||||
| 
 | ||||
|         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 { | ||||
|             if (defaultKvStore.getBoolean("firstrun", true)) { | ||||
|                 finishAffinity(); | ||||
|             } else { | ||||
|                 super.onBackPressed(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @OnClick(R.id.finishTutorialButton) | ||||
|     public void finishTutorial() { | ||||
|         defaultKvStore.putBoolean("firstrun", false); | ||||
|         finish(); | ||||
|     } | ||||
| } | ||||
|  | @ -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)) | ||||
| } | ||||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -1,9 +1,8 @@ | |||
| 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 | ||||
| import org.wikipedia.csrf.CsrfTokenClient | ||||
| 
 | ||||
| /** | ||||
|  * This class acts as a Client to facilitate wiki page editing | ||||
|  | @ -14,8 +13,9 @@ import io.reactivex.Single | |||
|  */ | ||||
| class PageEditClient( | ||||
|     private val csrfTokenClient: CsrfTokenClient, | ||||
|     private val pageEditInterface: PageEditInterface, | ||||
|     private val pageEditInterface: PageEditInterface | ||||
| ) { | ||||
| 
 | ||||
|     /** | ||||
|      * Replace the content of a wiki page | ||||
|      * @param pageTitle   Title of the page to edit | ||||
|  | @ -23,60 +23,14 @@ class PageEditClient( | |||
|      * @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() | ||||
|                 } | ||||
|     fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.tokenBlocking) | ||||
|                 .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) | ||||
|             } | ||||
|             Observable.just(false) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Append text to the end of a wiki page | ||||
|  | @ -85,22 +39,14 @@ class PageEditClient( | |||
|      * @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()) | ||||
|     fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.tokenBlocking) | ||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|             } else { | ||||
|                 Observable.just(false) | ||||
|             } | ||||
|             Observable.just(false) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepend text to the beginning of a wiki page | ||||
|  | @ -109,48 +55,14 @@ class PageEditClient( | |||
|      * @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()) | ||||
|     fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.tokenBlocking) | ||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|             } else { | ||||
|                 Observable.just(false) | ||||
|             } | ||||
|             Observable.just(false) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set new labels to Wikibase server of commons | ||||
|  | @ -160,42 +72,24 @@ class PageEditClient( | |||
|      * @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 } | ||||
|     fun setCaptions(summary: String, title: String, | ||||
|                     language: String, value: String) : Observable<Int>{ | ||||
|         return try { | ||||
|             pageEditInterface.postCaptions(summary, title, language, | ||||
|                 value, csrfTokenClient.tokenBlocking).map { it.success } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|             } else { | ||||
|                 Observable.just(0) | ||||
|             } | ||||
|             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() | ||||
|     fun getCurrentWikiText(title: String): Single<String?> { | ||||
|         return pageEditInterface.getWikiText(title).map { | ||||
|             it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,17 +1,12 @@ | |||
| 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 | ||||
| import org.wikipedia.dataclient.Service | ||||
| import org.wikipedia.dataclient.mwapi.MwQueryResponse | ||||
| import org.wikipedia.edit.Edit | ||||
| import org.wikipedia.wikidata.Entities | ||||
| import retrofit2.http.* | ||||
| 
 | ||||
| /** | ||||
|  * This interface facilitates wiki commons page editing services to the Networking module | ||||
|  | @ -32,40 +27,13 @@ interface PageEditInterface { | |||
|      */ | ||||
|     @FormUrlEncoded | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @POST(MW_API_PREFIX + "action=edit") | ||||
|     @POST(Service.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, | ||||
|         @Field("token") token: String | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -79,12 +47,12 @@ interface PageEditInterface { | |||
|      */ | ||||
|     @FormUrlEncoded | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @POST(MW_API_PREFIX + "action=edit") | ||||
|     @POST(Service.MW_API_PREFIX + "action=edit") | ||||
|     fun postAppendEdit( | ||||
|         @Field("title") title: String, | ||||
|         @Field("summary") summary: String, | ||||
|         @Field("appendtext") appendText: String, | ||||
|         @Field("token") token: String, | ||||
|         @Field("token") token: String | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -98,44 +66,36 @@ interface PageEditInterface { | |||
|      */ | ||||
|     @FormUrlEncoded | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @POST(MW_API_PREFIX + "action=edit") | ||||
|     @POST(Service.MW_API_PREFIX + "action=edit") | ||||
|     fun postPrependEdit( | ||||
|         @Field("title") title: String, | ||||
|         @Field("summary") summary: String, | ||||
|         @Field("prependtext") prependText: String, | ||||
|         @Field("token") token: String, | ||||
|         @Field("token") token: String | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @POST(MW_API_PREFIX + "action=edit§ion=new") | ||||
|     fun postNewSection( | ||||
|         @Field("title") title: String, | ||||
|         @Field("summary") summary: String, | ||||
|         @Field("sectiontitle") sectionTitle: String, | ||||
|         @Field("text") sectionText: String, | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2") | ||||
|     @POST(Service.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, | ||||
|         @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 wiki text for provided file names | ||||
|      * @param titles : Name of the file | ||||
|      * @return Single<MwQueryResult> | ||||
|      */ | ||||
|     @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") | ||||
|     @GET( | ||||
|         Service.MW_API_PREFIX + | ||||
|                 "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=" | ||||
|     ) | ||||
|     fun getWikiText( | ||||
|         @Query("titles") title: String, | ||||
|         @Query("titles") title: String | ||||
|     ): Single<MwQueryResponse?> | ||||
| } | ||||
|  | @ -1,10 +1,11 @@ | |||
| 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 fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF | ||||
| import io.reactivex.Observable | ||||
| import org.wikipedia.csrf.CsrfTokenClient | ||||
| import org.wikipedia.dataclient.Service | ||||
| import org.wikipedia.dataclient.mwapi.MwPostResponse | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Named | ||||
| import javax.inject.Singleton | ||||
|  | @ -14,33 +15,22 @@ import javax.inject.Singleton | |||
|  * 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) | ||||
|                 } | ||||
|             } | ||||
| class ThanksClient @Inject constructor( | ||||
|     @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, | ||||
|     @param:Named("commons-service") private val service: Service | ||||
| ) { | ||||
|     /** | ||||
|      * 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> { | ||||
|         return try { | ||||
|             service.thank(revisionId.toString(), null, csrfTokenClient.tokenBlocking, CommonsApplication.getInstance().userAgent) | ||||
|                 .map { mwThankPostResponse -> mwThankPostResponse.result.success== 1 } | ||||
|         } catch (throwable: Throwable) { | ||||
|             Observable.just(false) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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?> | ||||
| } | ||||
|  | @ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										44
									
								
								app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java
									
										
									
									
									
										Normal 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); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
							
								
								
									
										496
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										496
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,496 @@ | |||
| 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 fr.free.nrw.commons.utils.ActivityUtils; | ||||
| import java.util.Locale; | ||||
| import org.wikipedia.AppAdapter; | ||||
| import org.wikipedia.dataclient.ServiceFactory; | ||||
| import org.wikipedia.dataclient.WikiSite; | ||||
| import org.wikipedia.dataclient.mwapi.MwQueryResponse; | ||||
| import org.wikipedia.login.LoginClient; | ||||
| import org.wikipedia.login.LoginClient.LoginCallback; | ||||
| import org.wikipedia.login.LoginResult; | ||||
| 
 | ||||
| import javax.inject.Inject; | ||||
| import javax.inject.Named; | ||||
| 
 | ||||
| import butterknife.BindView; | ||||
| import butterknife.ButterKnife; | ||||
| import butterknife.OnClick; | ||||
| import butterknife.OnEditorAction; | ||||
| import butterknife.OnFocusChange; | ||||
| import fr.free.nrw.commons.BuildConfig; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.Utils; | ||||
| import fr.free.nrw.commons.WelcomeActivity; | ||||
| import fr.free.nrw.commons.contributions.MainActivity; | ||||
| import fr.free.nrw.commons.di.ApplicationlessInjection; | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; | ||||
| 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; | ||||
|     final  String saveProgressDailog="ProgressDailog_state"; | ||||
|     final String saveErrorMessage ="errorMessage"; | ||||
|     final String saveUsername="username"; | ||||
|     final  String savePassword="password"; | ||||
|     @Override | ||||
|     public void onCreate(Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         ApplicationlessInjection | ||||
|                 .getInstance(this.getApplicationContext()) | ||||
|                 .getCommonsApplicationComponent() | ||||
|                 .inject(this); | ||||
| 
 | ||||
|         boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode(); | ||||
|         setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme); | ||||
|         getDelegate().installViewFactory(); | ||||
|         getDelegate().onCreate(savedInstanceState); | ||||
| 
 | ||||
|         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 (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(), Locale.getDefault().getLanguage(), new LoginCallback() { | ||||
|                                     @Override | ||||
|                                     public void success(@NonNull LoginResult result) { | ||||
|                                         Timber.d("Login Success"); | ||||
|                                         onLoginSuccess(result); | ||||
|                                     } | ||||
| 
 | ||||
|                                     @Override | ||||
|                                     public void twoFactorPrompt(@NonNull Throwable caught, | ||||
|                                                                 @Nullable String token) { | ||||
|                                         Timber.d("Requesting 2FA prompt"); | ||||
|                                         hideProgress(); | ||||
|                                         askUserForTwoFactorAuth(); | ||||
|                                     } | ||||
| 
 | ||||
|                                     @Override | ||||
|                                     public void passwordResetPrompt(@Nullable String token) { | ||||
|                                         Timber.d("Showing password reset prompt"); | ||||
|                                         hideProgress(); | ||||
|                                         showPasswordResetPrompt(); | ||||
|                                     } | ||||
| 
 | ||||
|                                     @Override | ||||
|                                     public void error(@NonNull Throwable caught) { | ||||
|                                         Timber.e(caught); | ||||
|                                         hideProgress(); | ||||
|                                         showMessageAndCancelDialog(caught.getLocalizedMessage()); | ||||
|                                     } | ||||
|                                 }); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
|                     public void onFailure(Call<MwQueryResponse> call, Throwable t) { | ||||
|                         Timber.e(t); | ||||
|                         showMessageAndCancelDialog(t.getLocalizedMessage()); | ||||
|                     } | ||||
|                 }); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private void hideProgress() { | ||||
|         progressDialog.dismiss(); | ||||
|     } | ||||
| 
 | ||||
|     private void showPasswordResetPrompt() { | ||||
|         showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword)); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * This function is called when user skips the login. | ||||
|      * It redirects the user to Explore Activity. | ||||
|      */ | ||||
|     private void performSkipLogin() { | ||||
|         applicationKvStore.putBoolean("login_skipped", true); | ||||
|         MainActivity.startYourself(this); | ||||
|         finish(); | ||||
|     } | ||||
| 
 | ||||
|     private void showLoggingProgressBar() { | ||||
|         progressDialog = new ProgressDialog(this); | ||||
|         progressDialog.setIndeterminate(true); | ||||
|         progressDialog.setTitle(getString(R.string.logging_in_title)); | ||||
|         progressDialog.setMessage(getString(R.string.logging_in_message)); | ||||
|         progressDialog.setCanceledOnTouchOutside(false); | ||||
|         progressDialog.show(); | ||||
|     } | ||||
| 
 | ||||
|     private void onLoginSuccess(LoginResult loginResult) { | ||||
|         if (!progressDialog.isShowing()) { | ||||
|             // no longer attached to activity! | ||||
|             return; | ||||
|         } | ||||
|         compositeDisposable.clear(); | ||||
|         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() { | ||||
|         ActivityUtils.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); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onSaveInstanceState(Bundle outState) { | ||||
|         // if progressDialog is visible during the configuration change  then store state as  true else false so that | ||||
|         // we maintain visibility of progressDailog after configuration change | ||||
|         if(progressDialog!=null&&progressDialog.isShowing()) { | ||||
|             outState.putBoolean(saveProgressDailog,true); | ||||
|         } else { | ||||
|             outState.putBoolean(saveProgressDailog,false); | ||||
|         } | ||||
|         outState.putString(saveErrorMessage,errorMessage.getText().toString()); //Save the errorMessage | ||||
|         outState.putString(saveUsername,getUsername()); // Save the username | ||||
|         outState.putString(savePassword,getPassword()); // Save the password | ||||
|     } | ||||
|     private String getUsername() { | ||||
|         return usernameEdit.getText().toString(); | ||||
|     } | ||||
|     private String getPassword(){ | ||||
|         return  passwordEdit.getText().toString(); | ||||
|   } | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onRestoreInstanceState(final Bundle savedInstanceState) { | ||||
|         super.onRestoreInstanceState(savedInstanceState); | ||||
|         usernameEdit.setText(savedInstanceState.getString(saveUsername)); | ||||
|         passwordEdit.setText(savedInstanceState.getString(savePassword)); | ||||
|         if(savedInstanceState.getBoolean(saveProgressDailog)) { | ||||
|             performLogin(); | ||||
|         } | ||||
|         String errorMessage=savedInstanceState.getString(saveErrorMessage); | ||||
|         if(sessionManager.isUserLoggedIn()) { | ||||
|             showMessage(R.string.login_success, R.color.primaryDarkColor); | ||||
|         } else { | ||||
|             showMessage(errorMessage, R.color.secondaryDarkColor); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										36
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LogoutClient.java
									
										
									
									
									
										Normal 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()))); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										149
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,149 @@ | |||
| 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; | ||||
| 
 | ||||
|     @Inject | ||||
|     public SessionManager(Context context, | ||||
|                           @Named("default_preferences") JsonKvStore defaultKvStore) { | ||||
|         this.context = context; | ||||
|         this.currentAccount = null; | ||||
|         this.defaultKvStore = defaultKvStore; | ||||
|     } | ||||
| 
 | ||||
|     private boolean createAccount(@NonNull String userName, @NonNull String password) { | ||||
|         Account account = getCurrentAccount(); | ||||
|         if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) { | ||||
|             removeAccount(); | ||||
|             account = new Account(userName, BuildConfig.ACCOUNT_TYPE); | ||||
|             return accountManager().addAccountExplicitly(account, password, null); | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     private void removeAccount() { | ||||
|         Account account = getCurrentAccount(); | ||||
|         if (account != null) { | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { | ||||
|                 accountManager().removeAccountExplicitly(account); | ||||
|             } else { | ||||
|                 //noinspection deprecation | ||||
|                 accountManager().removeAccount(account, null, null); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void updateAccount(LoginResult result) { | ||||
|         boolean accountCreated = createAccount(result.getUserName(), result.getPassword()); | ||||
|         if (accountCreated) { | ||||
|             setPassword(result.getPassword()); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private void setPassword(@NonNull String password) { | ||||
|         Account account = getCurrentAccount(); | ||||
|         if (account != null) { | ||||
|             accountManager().setPassword(account, password); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return Account|null | ||||
|      */ | ||||
|     @Nullable | ||||
|     public Account getCurrentAccount() { | ||||
|         if (currentAccount == null) { | ||||
|             AccountManager accountManager = AccountManager.get(context); | ||||
|             Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE); | ||||
|             if (allAccounts.length != 0) { | ||||
|                 currentAccount = allAccounts[0]; | ||||
|             } | ||||
|         } | ||||
|         return currentAccount; | ||||
|     } | ||||
| 
 | ||||
|     public boolean doesAccountExist() { | ||||
|         return getCurrentAccount() != null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public String getUserName() { | ||||
|         Account account = getCurrentAccount(); | ||||
|         return account == null ? null : account.name; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public String getPassword() { | ||||
|         Account account = getCurrentAccount(); | ||||
|         return account == null ? null : accountManager().getPassword(account); | ||||
|     } | ||||
| 
 | ||||
|     private AccountManager accountManager() { | ||||
|         return AccountManager.get(context); | ||||
|     } | ||||
| 
 | ||||
|     public boolean isUserLoggedIn() { | ||||
|         return defaultKvStore.getBoolean("isUserLoggedIn", false); | ||||
|     } | ||||
| 
 | ||||
|     void setUserLoggedIn(boolean isLoggedIn) { | ||||
|         defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn); | ||||
|     } | ||||
| 
 | ||||
|     public void forceLogin(Context context) { | ||||
|         if (context != null) { | ||||
|             LoginActivity.startYourself(context); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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; | ||||
|                 }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return a corresponding boolean preference | ||||
|      * | ||||
|      * @param key | ||||
|      * @return | ||||
|      */ | ||||
|     public boolean getPreference(String key) { | ||||
|         return defaultKvStore.getBoolean(key); | ||||
|     } | ||||
| } | ||||
|  | @ -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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|             } | ||||
|     } | ||||
| } | ||||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -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(); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
|  | @ -1,217 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.csrf | ||||
| 
 | ||||
| import androidx.annotation.VisibleForTesting | ||||
| import fr.free.nrw.commons.auth.SessionManager | ||||
| import fr.free.nrw.commons.auth.login.LoginCallback | ||||
| import fr.free.nrw.commons.auth.login.LoginClient | ||||
| import fr.free.nrw.commons.auth.login.LoginFailedException | ||||
| import fr.free.nrw.commons.auth.login.LoginResult | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import retrofit2.Call | ||||
| import retrofit2.Response | ||||
| import timber.log.Timber | ||||
| import java.util.concurrent.Callable | ||||
| import java.util.concurrent.Executors.newSingleThreadExecutor | ||||
| 
 | ||||
| class CsrfTokenClient( | ||||
|     private val sessionManager: SessionManager, | ||||
|     private val csrfTokenInterface: CsrfTokenInterface, | ||||
|     private val loginClient: LoginClient, | ||||
|     private val logoutClient: LogoutClient, | ||||
| ) { | ||||
|     private var retries = 0 | ||||
|     private var csrfTokenCall: Call<MwQueryResponse?>? = null | ||||
| 
 | ||||
|     @Throws(Throwable::class) | ||||
|     fun getTokenBlocking(): String { | ||||
|         var token = "" | ||||
|         val userName = sessionManager.userName ?: "" | ||||
|         val password = sessionManager.password ?: "" | ||||
| 
 | ||||
|         for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) { | ||||
|             try { | ||||
|                 if (retry > 0) { | ||||
|                     // Log in explicitly | ||||
|                     loginClient.loginBlocking(userName, password) | ||||
|                 } | ||||
| 
 | ||||
|                 // Get CSRFToken response off the main thread. | ||||
|                 val response = | ||||
|                     newSingleThreadExecutor() | ||||
|                         .submit( | ||||
|                             Callable { | ||||
|                                 csrfTokenInterface.getCsrfTokenCall().execute() | ||||
|                             }, | ||||
|                         ).get() | ||||
| 
 | ||||
|                 if (response | ||||
|                         .body() | ||||
|                         ?.query() | ||||
|                         ?.csrfToken() | ||||
|                         .isNullOrEmpty() | ||||
|                 ) { | ||||
|                     continue | ||||
|                 } | ||||
| 
 | ||||
|                 token = response.body()!!.query()!!.csrfToken()!! | ||||
|                 if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { | ||||
|                     throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|                 } | ||||
|                 break | ||||
|             } catch (e: LoginFailedException) { | ||||
|                 throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|             } catch (t: Throwable) { | ||||
|                 Timber.w(t) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (token.isEmpty() || token == ANON_TOKEN) { | ||||
|             throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|         } | ||||
|         return token | ||||
|     } | ||||
| 
 | ||||
|     @VisibleForTesting | ||||
|     fun request( | ||||
|         service: CsrfTokenInterface, | ||||
|         cb: Callback, | ||||
|     ): Call<MwQueryResponse?> = | ||||
|         requestToken( | ||||
|             service, | ||||
|             object : Callback { | ||||
|                 override fun success(token: String?) { | ||||
|                     if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { | ||||
|                         retryWithLogin(cb) { | ||||
|                             InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|                         } | ||||
|                     } else { | ||||
|                         cb.success(token) | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } | ||||
| 
 | ||||
|                 override fun twoFactorPrompt() = cb.twoFactorPrompt() | ||||
| 
 | ||||
|                 override fun emailAuthPrompt() = cb.emailAuthPrompt() | ||||
|             }, | ||||
|         ) | ||||
| 
 | ||||
|     @VisibleForTesting | ||||
|     fun requestToken( | ||||
|         service: CsrfTokenInterface, | ||||
|         cb: Callback, | ||||
|     ): Call<MwQueryResponse?> { | ||||
|         val call = service.getCsrfTokenCall() | ||||
|         call.enqueue( | ||||
|             object : retrofit2.Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.success(response.body()!!.query()!!.csrfToken()) | ||||
|                 } | ||||
| 
 | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.failure(t) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|         return call | ||||
|     } | ||||
| 
 | ||||
|     private fun retryWithLogin( | ||||
|         callback: Callback, | ||||
|         caught: () -> Throwable?, | ||||
|     ) { | ||||
|         val userName = sessionManager.userName | ||||
|         val password = sessionManager.password | ||||
|         if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { | ||||
|             retries++ | ||||
|             logoutClient.logout() | ||||
|             login(userName, password, callback) { | ||||
|                 Timber.i("retrying...") | ||||
|                 cancel() | ||||
|                 csrfTokenCall = request(csrfTokenInterface, callback) | ||||
|             } | ||||
|         } else { | ||||
|             callback.failure(caught()) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun login( | ||||
|         username: String, | ||||
|         password: String, | ||||
|         callback: Callback, | ||||
|         retryCallback: () -> Unit, | ||||
|     ) = loginClient.request( | ||||
|         username, | ||||
|         password, | ||||
|         object : LoginCallback { | ||||
|             override fun success(loginResult: LoginResult) { | ||||
|                 if (loginResult.pass) { | ||||
|                     sessionManager.updateAccount(loginResult) | ||||
|                     retryCallback() | ||||
|                 } else { | ||||
|                     callback.failure(LoginFailedException(loginResult.message)) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             override fun twoFactorPrompt( | ||||
|                 loginResult: LoginResult, | ||||
|                 caught: Throwable, | ||||
|                 token: String?, | ||||
|             ) = callback.twoFactorPrompt() | ||||
| 
 | ||||
|             override fun emailAuthPrompt( | ||||
|                 loginResult: LoginResult, | ||||
|                 caught: Throwable, | ||||
|                 token: String?, | ||||
|             ) = callback.emailAuthPrompt() | ||||
| 
 | ||||
|             // Should not happen here, but call the callback just in case. | ||||
|             override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) | ||||
| 
 | ||||
|             override fun error(caught: Throwable) = callback.failure(caught) | ||||
|         }, | ||||
|     ) | ||||
| 
 | ||||
|     private fun cancel() { | ||||
|         loginClient.cancel() | ||||
|         if (csrfTokenCall != null) { | ||||
|             csrfTokenCall!!.cancel() | ||||
|             csrfTokenCall = null | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     interface Callback { | ||||
|         fun success(token: String?) | ||||
| 
 | ||||
|         fun failure(caught: Throwable?) | ||||
| 
 | ||||
|         fun twoFactorPrompt() | ||||
| 
 | ||||
|         fun emailAuthPrompt() | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val ANON_TOKEN = "+\\" | ||||
|         private const val MAX_RETRIES = 1 | ||||
|         private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2 | ||||
|         const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure." | ||||
|         const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class InvalidLoginTokenException( | ||||
|     message: String, | ||||
| ) : Exception(message) | ||||
|  | @ -1,13 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.csrf | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import retrofit2.Call | ||||
| import retrofit2.http.GET | ||||
| import retrofit2.http.Headers | ||||
| 
 | ||||
| interface CsrfTokenInterface { | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf") | ||||
|     fun getCsrfTokenCall(): Call<MwQueryResponse?> | ||||
| } | ||||
|  | @ -1,12 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.csrf | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class LogoutClient | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val store: CommonsCookieStorage, | ||||
|     ) { | ||||
|         fun logout() = store.clear() | ||||
|     } | ||||
|  | @ -1,21 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| interface LoginCallback { | ||||
|     fun success(loginResult: LoginResult) | ||||
| 
 | ||||
|     fun twoFactorPrompt( | ||||
|         loginResult: LoginResult, | ||||
|         caught: Throwable, | ||||
|         token: String?, | ||||
|     ) | ||||
| 
 | ||||
|     fun emailAuthPrompt( | ||||
|         loginResult: LoginResult, | ||||
|         caught: Throwable, | ||||
|         token: String?, | ||||
|     ) | ||||
| 
 | ||||
|     fun passwordResetPrompt(token: String?) | ||||
| 
 | ||||
|     fun error(caught: Throwable) | ||||
| } | ||||
|  | @ -1,276 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| import android.text.TextUtils | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult | ||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.android.schedulers.AndroidSchedulers | ||||
| import io.reactivex.schedulers.Schedulers | ||||
| import retrofit2.Call | ||||
| import retrofit2.Callback | ||||
| import retrofit2.Response | ||||
| import timber.log.Timber | ||||
| import java.io.IOException | ||||
| 
 | ||||
| /** | ||||
|  * Responsible for making login related requests to the server. | ||||
|  */ | ||||
| class LoginClient( | ||||
|     private val loginInterface: LoginInterface, | ||||
| ) { | ||||
|     private var tokenCall: Call<MwQueryResponse?>? = null | ||||
|     private var loginCall: Call<LoginResponse?>? = null | ||||
| 
 | ||||
|     /** | ||||
|      * userLanguage | ||||
|      * It holds the value of the user's device language code. | ||||
|      * For example, if user's device language is English it will hold En | ||||
|      * The value will be fetched when the user clicks Login Button in the LoginActivity | ||||
|      */ | ||||
|     private var userLanguage = "" | ||||
| 
 | ||||
|     private fun getLoginToken() = loginInterface.getLoginToken() | ||||
| 
 | ||||
|     fun request( | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         cb: LoginCallback, | ||||
|     ) { | ||||
|         cancel() | ||||
| 
 | ||||
|         tokenCall = getLoginToken() | ||||
|         tokenCall!!.enqueue( | ||||
|             object : Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) { | ||||
|                     login( | ||||
|                         userName, | ||||
|                         password, | ||||
|                         null, | ||||
|                         null, | ||||
|                         null, | ||||
|                         response.body()!!.query()!!.loginToken(), | ||||
|                         userLanguage, | ||||
|                         cb, | ||||
|                     ) | ||||
|                 } | ||||
| 
 | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     caught: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.error(caught) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun login( | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         retypedPassword: String?, | ||||
|         twoFactorCode: String?, | ||||
|         emailAuthCode: String?, | ||||
|         loginToken: String?, | ||||
|         userLanguage: String, | ||||
|         cb: LoginCallback, | ||||
|     ) { | ||||
|         this.userLanguage = userLanguage | ||||
| 
 | ||||
|         loginCall = | ||||
|             if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { | ||||
|                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|             } else { | ||||
|                 loginInterface.postLogIn( | ||||
|                     userName, | ||||
|                     password, | ||||
|                     retypedPassword, | ||||
|                     twoFactorCode, | ||||
|                     emailAuthCode, | ||||
|                     loginToken, | ||||
|                     userLanguage, | ||||
|                     true, | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|         loginCall!!.enqueue( | ||||
|             object : Callback<LoginResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<LoginResponse?>, | ||||
|                     response: Response<LoginResponse?>, | ||||
|                 ) { | ||||
|                     val loginResult = response.body()?.toLoginResult(password) | ||||
|                     if (loginResult != null) { | ||||
|                         if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { | ||||
|                             // The server could do some transformations on user names, e.g. on some | ||||
|                             // wikis is uppercases the first letter. | ||||
|                             getExtendedInfo(loginResult.userName, loginResult, cb) | ||||
|                         } else if ("UI" == loginResult.status) { | ||||
|                             when (loginResult) { | ||||
|                                 is OAuthResult -> | ||||
|                                     cb.twoFactorPrompt( | ||||
|                                         loginResult, | ||||
|                                         LoginFailedException(loginResult.message), | ||||
|                                         loginToken, | ||||
|                                     ) | ||||
| 
 | ||||
|                                 is EmailAuthResult -> | ||||
|                                     cb.emailAuthPrompt( | ||||
|                                         loginResult, | ||||
|                                         LoginFailedException(loginResult.message), | ||||
|                                         loginToken | ||||
|                                     ) | ||||
| 
 | ||||
|                                 is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) | ||||
| 
 | ||||
|                                 is LoginResult.Result -> | ||||
|                                     cb.error( | ||||
|                                         LoginFailedException(loginResult.message), | ||||
|                                     ) | ||||
|                             } | ||||
|                         } else { | ||||
|                             cb.error(LoginFailedException(loginResult.message)) | ||||
|                         } | ||||
|                     } else { | ||||
|                         cb.error(IOException("Login failed. Unexpected response.")) | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 override fun onFailure( | ||||
|                     call: Call<LoginResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.error(t) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun doLogin( | ||||
|         username: String, | ||||
|         password: String, | ||||
|         lastLoginResult: LoginResult?, | ||||
|         twoFactorCode: String, | ||||
|         userLanguage: String, | ||||
|         loginCallback: LoginCallback, | ||||
|     ) { | ||||
|         getLoginToken().enqueue( | ||||
|             object : Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) = if (response.isSuccessful) { | ||||
|                     val loginToken = response.body()?.query()?.loginToken() | ||||
|                     loginToken?.let { | ||||
|                         login(username, password, null, | ||||
|                             if (lastLoginResult is OAuthResult) twoFactorCode else null, | ||||
|                             if (lastLoginResult is EmailAuthResult) twoFactorCode else null, | ||||
|                             it, userLanguage, loginCallback) | ||||
|                     } ?: run { | ||||
|                         loginCallback.error(IOException("Failed to retrieve login token")) | ||||
|                     } | ||||
|                 } else { | ||||
|                     loginCallback.error(IOException("Failed to retrieve login token")) | ||||
|                 } | ||||
| 
 | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     loginCallback.error(t) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     @Throws(Throwable::class) | ||||
|     fun loginBlocking( | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         twoFactorCode: String? = null, | ||||
|         emailAuthCode: String? = null | ||||
|     ) { | ||||
|         val tokenResponse = getLoginToken().execute() | ||||
|         if (tokenResponse | ||||
|                 .body() | ||||
|                 ?.query() | ||||
|                 ?.loginToken() | ||||
|                 .isNullOrEmpty() | ||||
|         ) { | ||||
|             throw IOException("Unexpected response when getting login token.") | ||||
|         } | ||||
| 
 | ||||
|         val loginToken = tokenResponse.body()?.query()?.loginToken() | ||||
|         val tempLoginCall = | ||||
|             if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) { | ||||
|                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|             } else { | ||||
|                 loginInterface.postLogIn( | ||||
|                     userName, | ||||
|                     password, | ||||
|                     null, | ||||
|                     twoFactorCode, | ||||
|                     emailAuthCode, | ||||
|                     loginToken, | ||||
|                     userLanguage, | ||||
|                     true, | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|         val response = tempLoginCall.execute() | ||||
|         val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.") | ||||
|         val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.") | ||||
| 
 | ||||
|         if ("UI" == loginResult.status) { | ||||
|             if (loginResult is OAuthResult || loginResult is EmailAuthResult) { | ||||
|                 // TODO: Find a better way to boil up the warning about 2FA | ||||
|                 throw LoginFailedException(loginResult.message) | ||||
|             } | ||||
|             throw LoginFailedException(loginResult.message) | ||||
|         } | ||||
| 
 | ||||
|         if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) { | ||||
|             throw LoginFailedException(loginResult.message) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun getExtendedInfo( | ||||
|         userName: String, | ||||
|         loginResult: LoginResult, | ||||
|         cb: LoginCallback, | ||||
|     ) = loginInterface | ||||
|         .getUserInfo(userName) | ||||
|         .subscribeOn(Schedulers.io()) | ||||
|         .observeOn(AndroidSchedulers.mainThread()) | ||||
|         .subscribe({ response: MwQueryResponse? -> | ||||
|             loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 | ||||
|             loginResult.groups = | ||||
|                 response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() | ||||
|             cb.success(loginResult) | ||||
|         }, { caught: Throwable -> | ||||
|             Timber.e(caught, "Login succeeded but getting group information failed. ") | ||||
|             cb.error(caught) | ||||
|         }) | ||||
| 
 | ||||
|     fun cancel() { | ||||
|         tokenCall?.let { | ||||
|             it.cancel() | ||||
|             tokenCall = null | ||||
|         } | ||||
| 
 | ||||
|         loginCall?.let { | ||||
|             it.cancel() | ||||
|             loginCall = null | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,5 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| class LoginFailedException( | ||||
|     message: String?, | ||||
| ) : Throwable(message) | ||||
|  | @ -1,48 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.Observable | ||||
| import retrofit2.Call | ||||
| import retrofit2.http.Field | ||||
| import retrofit2.http.FormUrlEncoded | ||||
| import retrofit2.http.GET | ||||
| import retrofit2.http.Headers | ||||
| import retrofit2.http.POST | ||||
| import retrofit2.http.Query | ||||
| 
 | ||||
| interface LoginInterface { | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login") | ||||
|     fun getLoginToken(): Call<MwQueryResponse?> | ||||
| 
 | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @FormUrlEncoded | ||||
|     @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") | ||||
|     fun postLogIn( | ||||
|         @Field("username") user: String?, | ||||
|         @Field("password") pass: String?, | ||||
|         @Field("logintoken") token: String?, | ||||
|         @Field("uselang") userLanguage: String?, | ||||
|         @Field("loginreturnurl") url: String?, | ||||
|     ): Call<LoginResponse?> | ||||
| 
 | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|     @FormUrlEncoded | ||||
|     @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") | ||||
|     fun postLogIn( | ||||
|         @Field("username") user: String?, | ||||
|         @Field("password") pass: String?, | ||||
|         @Field("retype") retypedPass: String?, | ||||
|         @Field("OATHToken") twoFactorCode: String?, | ||||
|         @Field("token") emailAuthToken: String?, | ||||
|         @Field("logintoken") loginToken: String?, | ||||
|         @Field("uselang") userLanguage: String?, | ||||
|         @Field("logincontinue") loginContinue: Boolean, | ||||
|     ): Call<LoginResponse?> | ||||
| 
 | ||||
|     @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") | ||||
|     fun getUserInfo( | ||||
|         @Query("ususers") userName: String, | ||||
|     ): Observable<MwQueryResponse?> | ||||
| } | ||||
|  | @ -1,64 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| import com.google.gson.annotations.SerializedName | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.Result | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwServiceError | ||||
| 
 | ||||
| class LoginResponse { | ||||
|     @SerializedName("error") | ||||
|     val error: MwServiceError? = null | ||||
| 
 | ||||
|     @SerializedName("clientlogin") | ||||
|     private val clientLogin: ClientLogin? = null | ||||
| 
 | ||||
|     fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password) | ||||
| } | ||||
| 
 | ||||
| internal class ClientLogin { | ||||
|     private val status: String? = null | ||||
|     private val requests: List<Request>? = null | ||||
|     private val message: String? = null | ||||
| 
 | ||||
|     @SerializedName("username") | ||||
|     private val userName: String? = null | ||||
| 
 | ||||
|     fun toLoginResult(password: String): LoginResult { | ||||
|         var userMessage = message | ||||
|         if ("UI" == status) { | ||||
|             requests?.forEach { request -> | ||||
|                 request.id()?.let { | ||||
|                     if (it.endsWith("TOTPAuthenticationRequest")) { | ||||
|                         return OAuthResult(status, userName, password, message) | ||||
|                     } else if (it.endsWith("EmailAuthAuthenticationRequest")) { | ||||
|                         return EmailAuthResult(status, userName, password, message) | ||||
|                     } else if (it.endsWith("PasswordAuthenticationRequest")) { | ||||
|                         return ResetPasswordResult(status, userName, password, message) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } else if ("PASS" != status && "FAIL" != status) { | ||||
|             // TODO: String resource -- Looks like needed for others in this class too | ||||
|             userMessage = "An unknown error occurred." | ||||
|         } | ||||
|         return Result(status ?: "", userName, password, userMessage) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| internal class Request { | ||||
|     private val id: String? = null | ||||
|     private val required: String? = null | ||||
|     private val provider: String? = null | ||||
|     private val account: String? = null | ||||
|     internal val fields: Map<String, RequestField>? = null | ||||
| 
 | ||||
|     fun id(): String? = id | ||||
| } | ||||
| 
 | ||||
| internal class RequestField { | ||||
|     private val type: String? = null | ||||
|     private val label: String? = null | ||||
|     internal val help: String? = null | ||||
| } | ||||
|  | @ -1,40 +0,0 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| sealed class LoginResult( | ||||
|     val status: String, | ||||
|     val userName: String?, | ||||
|     val password: String?, | ||||
|     val message: String?, | ||||
| ) { | ||||
|     var userId = 0 | ||||
|     var groups = emptySet<String>() | ||||
|     val pass: Boolean get() = "PASS" == status | ||||
| 
 | ||||
|     class Result( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| 
 | ||||
|     class OAuthResult( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| 
 | ||||
|     class EmailAuthResult( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| 
 | ||||
|     class ResetPasswordResult( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| } | ||||
|  | @ -0,0 +1,111 @@ | |||
| package fr.free.nrw.commons.bookmarks; | ||||
| 
 | ||||
| import android.os.Bundle; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| 
 | ||||
| import android.widget.FrameLayout; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| 
 | ||||
| import com.google.android.material.tabs.TabLayout; | ||||
| 
 | ||||
| import fr.free.nrw.commons.contributions.MainActivity; | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; | ||||
| import fr.free.nrw.commons.explore.ParentViewPager; | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; | ||||
| import fr.free.nrw.commons.theme.BaseActivity; | ||||
| import javax.inject.Inject; | ||||
| 
 | ||||
| import butterknife.BindView; | ||||
| import butterknife.ButterKnife; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.contributions.ContributionController; | ||||
| import javax.inject.Named; | ||||
| 
 | ||||
| public class BookmarkFragment extends CommonsDaggerSupportFragment { | ||||
| 
 | ||||
|     private FragmentManager supportFragmentManager; | ||||
|     private BookmarksPagerAdapter adapter; | ||||
|     @BindView(R.id.viewPagerBookmarks) | ||||
|     ParentViewPager viewPager; | ||||
|     @BindView(R.id.tab_layout) | ||||
|     TabLayout tabLayout; | ||||
|     @BindView(R.id.fragmentContainer) | ||||
|     FrameLayout fragmentContainer; | ||||
| 
 | ||||
|     @Inject | ||||
|     ContributionController controller; | ||||
|     /** | ||||
|      * To check if the user is loggedIn or not. | ||||
|      */ | ||||
|     @Inject | ||||
|     @Named("default_preferences") | ||||
|     public | ||||
|     JsonKvStore applicationKvStore; | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static BookmarkFragment newInstance() { | ||||
|         BookmarkFragment fragment = new BookmarkFragment(); | ||||
|         fragment.setRetainInstance(true); | ||||
|         return fragment; | ||||
|     } | ||||
| 
 | ||||
|     public void setScroll(boolean canScroll) { | ||||
|         viewPager.setCanScroll(canScroll); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onCreate(@Nullable final Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     @Override | ||||
|     public View onCreateView(@NonNull final LayoutInflater inflater, | ||||
|         @Nullable final ViewGroup container, | ||||
|         @Nullable final Bundle savedInstanceState) { | ||||
|         super.onCreateView(inflater, container, savedInstanceState); | ||||
|         View view = inflater.inflate(R.layout.fragment_bookmarks, container, false); | ||||
|         ButterKnife.bind(this, view); | ||||
| 
 | ||||
|         // Activity can call methods in the fragment by acquiring a | ||||
|         // reference to the Fragment from FragmentManager, using findFragmentById() | ||||
|         supportFragmentManager = getChildFragmentManager(); | ||||
| 
 | ||||
|         adapter = new BookmarksPagerAdapter(supportFragmentManager, getContext(), | ||||
|             applicationKvStore.getBoolean("login_skipped")); | ||||
|         viewPager.setAdapter(adapter); | ||||
|         tabLayout.setupWithViewPager(viewPager); | ||||
| 
 | ||||
|         ((MainActivity) getActivity()).showTabs(); | ||||
|         ((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false); | ||||
| 
 | ||||
|         setupTabLayout(); | ||||
|         return view; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method sets up the tab layout. If the adapter has only one element it sets the | ||||
|      * visibility of tabLayout to gone. | ||||
|      */ | ||||
|     public void setupTabLayout() { | ||||
|         tabLayout.setVisibility(View.VISIBLE); | ||||
|         if (adapter.getCount() == 1) { | ||||
|             tabLayout.setVisibility(View.GONE); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     public void onBackPressed() { | ||||
|         if (((BookmarkListRootFragment) (adapter.getItem(tabLayout.getSelectedTabPosition()))) | ||||
|             .backPressed()) { | ||||
|             // The event is handled internally by the adapter , no further action required. | ||||
|             return; | ||||
|         } | ||||
|         // Event is not handled by the adapter ( performed back action ) change action bar. | ||||
|         ((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false); | ||||
|     } | ||||
| } | ||||
|  | @ -1,98 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks | ||||
| 
 | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import fr.free.nrw.commons.contributions.ContributionController | ||||
| import fr.free.nrw.commons.contributions.MainActivity | ||||
| import fr.free.nrw.commons.databinding.FragmentBookmarksBinding | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore | ||||
| import fr.free.nrw.commons.theme.BaseActivity | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Named | ||||
| 
 | ||||
| class BookmarkFragment : CommonsDaggerSupportFragment() { | ||||
|     private var adapter: BookmarksPagerAdapter? = null | ||||
| 
 | ||||
|     @JvmField | ||||
|     var binding: FragmentBookmarksBinding? = null | ||||
| 
 | ||||
|     @JvmField | ||||
|     @Inject | ||||
|     var controller: ContributionController? = null | ||||
| 
 | ||||
|     /** | ||||
|      * To check if the user is loggedIn or not. | ||||
|      */ | ||||
|     @JvmField | ||||
|     @Inject | ||||
|     @Named("default_preferences") | ||||
|     var applicationKvStore: JsonKvStore? = null | ||||
| 
 | ||||
|     fun setScroll(canScroll: Boolean) { | ||||
|         binding?.let { | ||||
|             it.viewPagerBookmarks.canScroll = canScroll | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         super.onCreateView(inflater, container, savedInstanceState) | ||||
|         binding = FragmentBookmarksBinding.inflate(inflater, container, false) | ||||
| 
 | ||||
|         // Activity can call methods in the fragment by acquiring a | ||||
|         // reference to the Fragment from FragmentManager, using findFragmentById() | ||||
|         val supportFragmentManager = childFragmentManager | ||||
| 
 | ||||
|         adapter = BookmarksPagerAdapter( | ||||
|             supportFragmentManager, requireContext(), | ||||
|             applicationKvStore!!.getBoolean("login_skipped") | ||||
|         ) | ||||
|         binding!!.viewPagerBookmarks.adapter = adapter | ||||
|         binding!!.tabLayout.setupWithViewPager(binding!!.viewPagerBookmarks) | ||||
| 
 | ||||
|         (requireActivity() as MainActivity).showTabs() | ||||
|         (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false) | ||||
| 
 | ||||
|         setupTabLayout() | ||||
|         return binding!!.root | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method sets up the tab layout. If the adapter has only one element it sets the | ||||
|      * visibility of tabLayout to gone. | ||||
|      */ | ||||
|     fun setupTabLayout() { | ||||
|         binding!!.tabLayout.visibility = View.VISIBLE | ||||
|         if (adapter!!.count == 1) { | ||||
|             binding!!.tabLayout.visibility = View.GONE | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     fun onBackPressed() { | ||||
|         if (((adapter!!.getItem(binding!!.tabLayout.selectedTabPosition)) as BookmarkListRootFragment).backPressed()) { | ||||
|             // The event is handled internally by the adapter , no further action required. | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         // Event is not handled by the adapter ( performed back action ) change action bar. | ||||
|         (requireActivity() as BaseActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(false) | ||||
|     } | ||||
| 
 | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         binding = null | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         fun newInstance(): BookmarkFragment = BookmarkFragment().apply { | ||||
|             retainInstance = true | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,256 @@ | |||
| package fr.free.nrw.commons.bookmarks; | ||||
| 
 | ||||
| import android.content.Context; | ||||
| import android.os.Bundle; | ||||
| import android.util.Log; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| import android.widget.AdapterView; | ||||
| import android.widget.FrameLayout; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| import butterknife.BindView; | ||||
| import butterknife.ButterKnife; | ||||
| import fr.free.nrw.commons.Media; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment; | ||||
| import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; | ||||
| import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; | ||||
| import fr.free.nrw.commons.category.CategoryImagesCallback; | ||||
| import fr.free.nrw.commons.category.GridViewAdapter; | ||||
| import fr.free.nrw.commons.contributions.MainActivity; | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; | ||||
| import fr.free.nrw.commons.media.MediaDetailPagerFragment; | ||||
| import fr.free.nrw.commons.navtab.NavTab; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Iterator; | ||||
| 
 | ||||
| public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements | ||||
|     FragmentManager.OnBackStackChangedListener, | ||||
|     MediaDetailPagerFragment.MediaDetailProvider, | ||||
|     AdapterView.OnItemClickListener, CategoryImagesCallback { | ||||
| 
 | ||||
|     private MediaDetailPagerFragment mediaDetails; | ||||
|     //private BookmarkPicturesFragment bookmarkPicturesFragment; | ||||
|     private BookmarkLocationsFragment bookmarkLocationsFragment; | ||||
|     public Fragment listFragment; | ||||
|     private BookmarksPagerAdapter bookmarksPagerAdapter; | ||||
| 
 | ||||
|     @BindView(R.id.explore_container) | ||||
|     FrameLayout container; | ||||
| 
 | ||||
|     public BookmarkListRootFragment() { | ||||
|         //empty constructor necessary otherwise crashes on recreate | ||||
|     } | ||||
| 
 | ||||
|     public BookmarkListRootFragment(Bundle bundle, BookmarksPagerAdapter bookmarksPagerAdapter) { | ||||
|         String title = bundle.getString("categoryName"); | ||||
|         int order = bundle.getInt("order"); | ||||
|         final int orderItem = bundle.getInt("orderItem"); | ||||
|         if (order == 0) { | ||||
|             listFragment = new BookmarkPicturesFragment(); | ||||
|         } else { | ||||
|             listFragment = new BookmarkLocationsFragment(); | ||||
|             if(orderItem == 2) { | ||||
|                 listFragment = new BookmarkItemsFragment(); | ||||
|             } | ||||
|         } | ||||
|         Bundle featuredArguments = new Bundle(); | ||||
|         featuredArguments.putString("categoryName", title); | ||||
|         listFragment.setArguments(featuredArguments); | ||||
|         this.bookmarksPagerAdapter = bookmarksPagerAdapter; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     @Override | ||||
|     public View onCreateView(@NonNull final LayoutInflater inflater, | ||||
|         @Nullable final ViewGroup container, | ||||
|         @Nullable final Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         View view = inflater.inflate(R.layout.fragment_featured_root, container, false); | ||||
|         ButterKnife.bind(this, view); | ||||
|         return view; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { | ||||
|         super.onViewCreated(view, savedInstanceState); | ||||
|         if (savedInstanceState == null) { | ||||
|             setFragment(listFragment, mediaDetails); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void setFragment(Fragment fragment, Fragment otherFragment) { | ||||
|         if (fragment.isAdded() && otherFragment != null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .hide(otherFragment) | ||||
|                 .show(fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit(); | ||||
|             getChildFragmentManager().executePendingTransactions(); | ||||
|         } else if (fragment.isAdded() && otherFragment == null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .show(fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit(); | ||||
|             getChildFragmentManager().executePendingTransactions(); | ||||
|         } else if (!fragment.isAdded() && otherFragment != null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .hide(otherFragment) | ||||
|                 .add(R.id.explore_container, fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit(); | ||||
|             getChildFragmentManager().executePendingTransactions(); | ||||
|         } else if (!fragment.isAdded()) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .replace(R.id.explore_container, fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit(); | ||||
|             getChildFragmentManager().executePendingTransactions(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void removeFragment(Fragment fragment) { | ||||
|         getChildFragmentManager() | ||||
|             .beginTransaction() | ||||
|             .remove(fragment) | ||||
|             .commit(); | ||||
|         getChildFragmentManager().executePendingTransactions(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onAttach(final Context context) { | ||||
|         super.onAttach(context); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onMediaClicked(int position) { | ||||
|         Log.d("deneme8", "on media clicked"); | ||||
|     /*container.setVisibility(View.VISIBLE); | ||||
|     ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); | ||||
|     mediaDetails = new MediaDetailPagerFragment(false, true, position); | ||||
|     setFragment(mediaDetails, bookmarkPicturesFragment);*/ | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index | ||||
|      * | ||||
|      * @param i It is the index of which media object is to be returned which is same as current | ||||
|      *          index of viewPager. | ||||
|      * @return Media Object | ||||
|      */ | ||||
|     @Override | ||||
|     public Media getMediaAtPosition(int i) { | ||||
|         if (bookmarksPagerAdapter.getMediaAdapter() == null) { | ||||
|             // not yet ready to return data | ||||
|             return null; | ||||
|         } else { | ||||
|             return (Media) bookmarksPagerAdapter.getMediaAdapter().getItem(i); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called on from getCount of MediaDetailPagerFragment The viewpager will contain | ||||
|      * same number of media items as that of media elements in adapter. | ||||
|      * | ||||
|      * @return Total Media count in the adapter | ||||
|      */ | ||||
|     @Override | ||||
|     public int getTotalMediaCount() { | ||||
|         if (bookmarksPagerAdapter.getMediaAdapter() == null) { | ||||
|             return 0; | ||||
|         } | ||||
|         return bookmarksPagerAdapter.getMediaAdapter().getCount(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Integer getContributionStateAt(int position) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload media detail fragment once media is nominated | ||||
|      * | ||||
|      * @param index item position that has been nominated | ||||
|      */ | ||||
|     @Override | ||||
|     public void refreshNominatedMedia(int index) { | ||||
|         if (mediaDetails != null && !listFragment.isVisible()) { | ||||
|             removeFragment(mediaDetails); | ||||
|             mediaDetails = new MediaDetailPagerFragment(false, true); | ||||
|             ((BookmarkFragment) getParentFragment()).setScroll(false); | ||||
|             setFragment(mediaDetails, listFragment); | ||||
|             mediaDetails.showImage(index); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called on success of API call for featured images or mobile uploads. The | ||||
|      * viewpager will notified that number of items have changed. | ||||
|      */ | ||||
|     @Override | ||||
|     public void viewPagerNotifyDataSetChanged() { | ||||
|         if (mediaDetails != null) { | ||||
|             mediaDetails.notifyDataSetChanged(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public boolean backPressed() { | ||||
|         //check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException | ||||
|         if (mediaDetails != null) { | ||||
|             if (mediaDetails.isVisible()) { | ||||
|                 // todo add get list fragment | ||||
|                 ((BookmarkFragment) getParentFragment()).setupTabLayout(); | ||||
|                 ArrayList<Integer> removed = mediaDetails.getRemovedItems(); | ||||
|                 removeFragment(mediaDetails); | ||||
|                 ((BookmarkFragment) getParentFragment()).setScroll(true); | ||||
|                 setFragment(listFragment, mediaDetails); | ||||
|                 ((MainActivity) getActivity()).showTabs(); | ||||
|                 if (listFragment instanceof BookmarkPicturesFragment) { | ||||
|                     GridViewAdapter adapter = ((GridViewAdapter) ((BookmarkPicturesFragment) listFragment) | ||||
|                         .getAdapter()); | ||||
|                     Iterator i = removed.iterator(); | ||||
|                     while (i.hasNext()) { | ||||
|                         adapter.remove(adapter.getItem((int) i.next())); | ||||
|                     } | ||||
|                     mediaDetails.clearRemoved(); | ||||
| 
 | ||||
|                 } | ||||
|             } else { | ||||
|                 moveToContributionsFragment(); | ||||
|             } | ||||
|         } else { | ||||
|             moveToContributionsFragment(); | ||||
|         } | ||||
|         // notify mediaDetails did not handled the backPressed further actions required. | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     void moveToContributionsFragment() { | ||||
|         ((MainActivity) getActivity()).setSelectedItemId(NavTab.CONTRIBUTIONS.code()); | ||||
|         ((MainActivity) getActivity()).showTabs(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onItemClick(AdapterView<?> parent, View view, int position, long id) { | ||||
|         Log.d("deneme8", "on media clicked"); | ||||
|         container.setVisibility(View.VISIBLE); | ||||
|         ((BookmarkFragment) getParentFragment()).tabLayout.setVisibility(View.GONE); | ||||
|         mediaDetails = new MediaDetailPagerFragment(false, true); | ||||
|         ((BookmarkFragment) getParentFragment()).setScroll(false); | ||||
|         setFragment(mediaDetails, listFragment); | ||||
|         mediaDetails.showImage(position); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onBackStackChanged() { | ||||
| 
 | ||||
|     } | ||||
| } | ||||
|  | @ -1,226 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks | ||||
| 
 | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.AdapterView | ||||
| import android.widget.AdapterView.OnItemClickListener | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentManager | ||||
| import fr.free.nrw.commons.Media | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment | ||||
| import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment | ||||
| import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment | ||||
| import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment | ||||
| import fr.free.nrw.commons.category.CategoryImagesCallback | ||||
| import fr.free.nrw.commons.category.GridViewAdapter | ||||
| import fr.free.nrw.commons.contributions.MainActivity | ||||
| import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment | ||||
| import fr.free.nrw.commons.media.MediaDetailPagerFragment | ||||
| import fr.free.nrw.commons.media.MediaDetailPagerFragment.Companion.newInstance | ||||
| import fr.free.nrw.commons.media.MediaDetailProvider | ||||
| import fr.free.nrw.commons.navtab.NavTab | ||||
| import timber.log.Timber | ||||
| 
 | ||||
| class BookmarkListRootFragment : CommonsDaggerSupportFragment, | ||||
|     FragmentManager.OnBackStackChangedListener, MediaDetailProvider, OnItemClickListener, | ||||
|     CategoryImagesCallback { | ||||
|     private var mediaDetails: MediaDetailPagerFragment? = null | ||||
|     private val bookmarkLocationsFragment: BookmarkLocationsFragment? = null | ||||
|     var listFragment: Fragment? = null | ||||
|     private var bookmarksPagerAdapter: BookmarksPagerAdapter? = null | ||||
| 
 | ||||
|     var binding: FragmentFeaturedRootBinding? = null | ||||
| 
 | ||||
|     constructor() | ||||
| 
 | ||||
|     constructor(bundle: Bundle, bookmarksPagerAdapter: BookmarksPagerAdapter) { | ||||
|         val title = bundle.getString("categoryName") | ||||
|         val order = bundle.getInt("order") | ||||
|         val orderItem = bundle.getInt("orderItem") | ||||
| 
 | ||||
|         when (order) { | ||||
|             0 -> listFragment = BookmarkPicturesFragment() | ||||
|             1 -> listFragment = BookmarkLocationsFragment() | ||||
|             3 -> listFragment = BookmarkCategoriesFragment() | ||||
|         } | ||||
|         if (orderItem == 2) { | ||||
|             listFragment = BookmarkItemsFragment() | ||||
|         } | ||||
| 
 | ||||
|         val featuredArguments = Bundle() | ||||
|         featuredArguments.putString("categoryName", title) | ||||
|         listFragment!!.setArguments(featuredArguments) | ||||
|         this.bookmarksPagerAdapter = bookmarksPagerAdapter | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View? { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         binding = FragmentFeaturedRootBinding.inflate(inflater, container, false) | ||||
|         return binding!!.getRoot() | ||||
|     } | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         if (savedInstanceState == null) { | ||||
|             setFragment(listFragment!!, mediaDetails) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun setFragment(fragment: Fragment, otherFragment: Fragment?) { | ||||
|         if (fragment.isAdded() && otherFragment != null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .hide(otherFragment) | ||||
|                 .show(fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit() | ||||
|             getChildFragmentManager().executePendingTransactions() | ||||
|         } else if (fragment.isAdded() && otherFragment == null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .show(fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit() | ||||
|             getChildFragmentManager().executePendingTransactions() | ||||
|         } else if (!fragment.isAdded() && otherFragment != null) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .hide(otherFragment) | ||||
|                 .add(R.id.explore_container, fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit() | ||||
|             getChildFragmentManager().executePendingTransactions() | ||||
|         } else if (!fragment.isAdded()) { | ||||
|             getChildFragmentManager() | ||||
|                 .beginTransaction() | ||||
|                 .replace(R.id.explore_container, fragment) | ||||
|                 .addToBackStack("CONTRIBUTION_LIST_FRAGMENT_TAG") | ||||
|                 .commit() | ||||
|             getChildFragmentManager().executePendingTransactions() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun removeFragment(fragment: Fragment) { | ||||
|         getChildFragmentManager() | ||||
|             .beginTransaction() | ||||
|             .remove(fragment) | ||||
|             .commit() | ||||
|         getChildFragmentManager().executePendingTransactions() | ||||
|     } | ||||
| 
 | ||||
|     override fun onMediaClicked(position: Int) { | ||||
|         Timber.d("on media clicked") | ||||
|         /*container.setVisibility(View.VISIBLE); | ||||
|     ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); | ||||
|     mediaDetails = new MediaDetailPagerFragment(false, true, position); | ||||
|     setFragment(mediaDetails, bookmarkPicturesFragment);*/ | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index | ||||
|      * | ||||
|      * @param i It is the index of which media object is to be returned which is same as current | ||||
|      * index of viewPager. | ||||
|      * @return Media Object | ||||
|      */ | ||||
|     override fun getMediaAtPosition(i: Int): Media? = | ||||
|         bookmarksPagerAdapter!!.mediaAdapter?.getItem(i) as Media? | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called on from getCount of MediaDetailPagerFragment The viewpager will contain | ||||
|      * same number of media items as that of media elements in adapter. | ||||
|      * | ||||
|      * @return Total Media count in the adapter | ||||
|      */ | ||||
|     override fun getTotalMediaCount(): Int = | ||||
|         bookmarksPagerAdapter!!.mediaAdapter?.count ?: 0 | ||||
| 
 | ||||
|     override fun getContributionStateAt(position: Int): Int? { | ||||
|         return null | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload media detail fragment once media is nominated | ||||
|      * | ||||
|      * @param index item position that has been nominated | ||||
|      */ | ||||
|     override fun refreshNominatedMedia(index: Int) { | ||||
|         if (mediaDetails != null && !listFragment!!.isVisible()) { | ||||
|             removeFragment(mediaDetails!!) | ||||
|             mediaDetails = newInstance(false, true) | ||||
|             (parentFragment as BookmarkFragment).setScroll(false) | ||||
|             setFragment(mediaDetails!!, listFragment) | ||||
|             mediaDetails!!.showImage(index) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method is called on success of API call for featured images or mobile uploads. The | ||||
|      * viewpager will notified that number of items have changed. | ||||
|      */ | ||||
|     override fun viewPagerNotifyDataSetChanged() { | ||||
|         if (mediaDetails != null) { | ||||
|             mediaDetails!!.notifyDataSetChanged() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun backPressed(): Boolean { | ||||
|         //check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException | ||||
|         if (mediaDetails != null) { | ||||
|             if (mediaDetails!!.isVisible()) { | ||||
|                 // todo add get list fragment | ||||
|                 (parentFragment as BookmarkFragment).setupTabLayout() | ||||
|                 val removed: ArrayList<Int> = mediaDetails!!.removedItems | ||||
|                 removeFragment(mediaDetails!!) | ||||
|                 (parentFragment as BookmarkFragment).setScroll(true) | ||||
|                 setFragment(listFragment!!, mediaDetails) | ||||
|                 (requireActivity() as MainActivity).showTabs() | ||||
|                 if (listFragment is BookmarkPicturesFragment) { | ||||
|                     val adapter = ((listFragment as BookmarkPicturesFragment) | ||||
|                         .getAdapter() as GridViewAdapter?) | ||||
|                     val i: MutableIterator<*> = removed.iterator() | ||||
|                     while (i.hasNext()) { | ||||
|                         adapter!!.remove(adapter.getItem(i.next() as Int)) | ||||
|                     } | ||||
|                     mediaDetails!!.clearRemoved() | ||||
|                 } | ||||
|             } else { | ||||
|                 moveToContributionsFragment() | ||||
|             } | ||||
|         } else { | ||||
|             moveToContributionsFragment() | ||||
|         } | ||||
|         // notify mediaDetails did not handled the backPressed further actions required. | ||||
|         return false | ||||
|     } | ||||
| 
 | ||||
|     fun moveToContributionsFragment() { | ||||
|         (requireActivity() as MainActivity).setSelectedItemId(NavTab.CONTRIBUTIONS.code()) | ||||
|         (requireActivity() as MainActivity).showTabs() | ||||
|     } | ||||
| 
 | ||||
|     override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { | ||||
|         Timber.d("on media clicked") | ||||
|         binding!!.exploreContainer.visibility = View.VISIBLE | ||||
|         (parentFragment as BookmarkFragment).binding!!.tabLayout.setVisibility(View.GONE) | ||||
|         mediaDetails = newInstance(false, true) | ||||
|         (parentFragment as BookmarkFragment).setScroll(false) | ||||
|         setFragment(mediaDetails!!, listFragment) | ||||
|         mediaDetails!!.showImage(position) | ||||
|     } | ||||
| 
 | ||||
|     override fun onBackStackChanged() = Unit | ||||
| 
 | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         binding = null | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,32 @@ | |||
| package fr.free.nrw.commons.bookmarks; | ||||
| 
 | ||||
| import androidx.fragment.app.Fragment; | ||||
| 
 | ||||
| /** | ||||
|  * Data class for handling a bookmark fragment and it title | ||||
|  */ | ||||
| public class BookmarkPages { | ||||
|     private Fragment page; | ||||
|     private String title; | ||||
| 
 | ||||
|     BookmarkPages(Fragment fragment, String title) { | ||||
|         this.title = title; | ||||
|         this.page = fragment; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return the fragment | ||||
|      * @return fragment object | ||||
|      */ | ||||
|     public Fragment getPage() { | ||||
|         return page; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return the fragment title | ||||
|      * @return title | ||||
|      */ | ||||
|     public String getTitle() { | ||||
|         return title; | ||||
|     } | ||||
| } | ||||
|  | @ -1,8 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks | ||||
| 
 | ||||
| import androidx.fragment.app.Fragment | ||||
| 
 | ||||
| data class BookmarkPages ( | ||||
|     val page: Fragment? = null, | ||||
|     val title: String? = null | ||||
| ) | ||||
|  | @ -0,0 +1,88 @@ | |||
| package fr.free.nrw.commons.bookmarks; | ||||
| 
 | ||||
| import android.content.Context; | ||||
| import android.os.Bundle; | ||||
| import android.widget.ListAdapter; | ||||
| 
 | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| import androidx.fragment.app.FragmentPagerAdapter; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| 
 | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; | ||||
| import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; | ||||
| 
 | ||||
| public class BookmarksPagerAdapter extends FragmentPagerAdapter { | ||||
| 
 | ||||
|     private ArrayList<BookmarkPages> pages; | ||||
| 
 | ||||
|     /** | ||||
|      * Default Constructor | ||||
|      * @param fm | ||||
|      * @param context | ||||
|      * @param onlyPictures is true if the fragment requires only BookmarkPictureFragment | ||||
|      *                     (i.e. when no user is logged in). | ||||
|      */ | ||||
|     BookmarksPagerAdapter(FragmentManager fm, Context context,boolean onlyPictures) { | ||||
|         super(fm); | ||||
|         pages = new ArrayList<>(); | ||||
|         Bundle picturesBundle = new Bundle(); | ||||
|         picturesBundle.putString("categoryName", context.getString(R.string.title_page_bookmarks_pictures)); | ||||
|         picturesBundle.putInt("order", 0); | ||||
|         pages.add(new BookmarkPages( | ||||
|                 new BookmarkListRootFragment(picturesBundle, this), | ||||
|                 context.getString(R.string.title_page_bookmarks_pictures))); | ||||
|         if (!onlyPictures) { | ||||
|             // if onlyPictures is false we also add the location fragment. | ||||
|             Bundle locationBundle = new Bundle(); | ||||
|             locationBundle.putString("categoryName", | ||||
|                 context.getString(R.string.title_page_bookmarks_locations)); | ||||
|             locationBundle.putInt("order", 1); | ||||
|             pages.add(new BookmarkPages( | ||||
|                 new BookmarkListRootFragment(locationBundle, this), | ||||
|                 context.getString(R.string.title_page_bookmarks_locations))); | ||||
| 
 | ||||
|             locationBundle.putInt("orderItem", 2); | ||||
|             pages.add(new BookmarkPages( | ||||
|                 new BookmarkListRootFragment(locationBundle, this), | ||||
|                 context.getString(R.string.title_page_bookmarks_items))); | ||||
|         } | ||||
|         notifyDataSetChanged(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Fragment getItem(int position) { | ||||
|         return pages.get(position).getPage(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public int getCount() { | ||||
|         return pages.size(); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     @Override | ||||
|     public CharSequence getPageTitle(int position) { | ||||
|         return pages.get(position).getTitle(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return the Adapter used to display the picture gridview | ||||
|      * @return adapter | ||||
|      */ | ||||
|     public ListAdapter getMediaAdapter() { | ||||
|         BookmarkPicturesFragment fragment = (BookmarkPicturesFragment)(((BookmarkListRootFragment)pages.get(0).getPage()).listFragment); | ||||
|         return fragment.getAdapter(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the pictures list for the bookmark fragment | ||||
|      */ | ||||
|     public void requestPictureListUpdate() { | ||||
|         BookmarkPicturesFragment fragment = (BookmarkPicturesFragment)(((BookmarkListRootFragment)pages.get(0).getPage()).listFragment); | ||||
|         fragment.onResume(); | ||||
|     } | ||||
| } | ||||
|  | @ -1,82 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.widget.ListAdapter | ||||
| import androidx.core.os.bundleOf | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentManager | ||||
| import androidx.fragment.app.FragmentPagerAdapter | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment | ||||
| 
 | ||||
| class BookmarksPagerAdapter internal constructor( | ||||
|     fm: FragmentManager, context: Context, onlyPictures: Boolean | ||||
| ) : FragmentPagerAdapter(fm) { | ||||
|     private val pages = mutableListOf<BookmarkPages>() | ||||
| 
 | ||||
|     /** | ||||
|      * Default Constructor | ||||
|      * @param fm | ||||
|      * @param context | ||||
|      * @param onlyPictures is true if the fragment requires only BookmarkPictureFragment | ||||
|      * (i.e. when no user is logged in). | ||||
|      */ | ||||
|     init { | ||||
|         pages.add( | ||||
|             BookmarkPages( | ||||
|                 BookmarkListRootFragment( | ||||
|                     bundleOf( | ||||
|                         "categoryName" to context.getString(R.string.title_page_bookmarks_pictures), | ||||
|                         "order" to 0 | ||||
|                     ), this | ||||
|                 ), context.getString(R.string.title_page_bookmarks_pictures) | ||||
|             ) | ||||
|         ) | ||||
|         if (!onlyPictures) { | ||||
|             // if onlyPictures is false we also add the location fragment. | ||||
|             val locationBundle = bundleOf( | ||||
|                 "categoryName" to context.getString(R.string.title_page_bookmarks_locations), | ||||
|                 "order" to 1 | ||||
|             ) | ||||
| 
 | ||||
|             pages.add( | ||||
|                 BookmarkPages( | ||||
|                     BookmarkListRootFragment(locationBundle, this), | ||||
|                     context.getString(R.string.title_page_bookmarks_locations) | ||||
|                 ) | ||||
|             ) | ||||
| 
 | ||||
|             locationBundle.putInt("orderItem", 2) | ||||
|             pages.add( | ||||
|                 BookmarkPages( | ||||
|                     BookmarkListRootFragment(locationBundle, this), | ||||
|                     context.getString(R.string.title_page_bookmarks_items) | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|         pages.add( | ||||
|             BookmarkPages( | ||||
|                 BookmarkListRootFragment( | ||||
|                     bundleOf( | ||||
|                         "categoryName" to context.getString(R.string.title_page_bookmarks_categories), | ||||
|                         "order" to 3 | ||||
|                     ), this), | ||||
|                 context.getString(R.string.title_page_bookmarks_categories) | ||||
|             ) | ||||
|         ) | ||||
|         notifyDataSetChanged() | ||||
|     } | ||||
| 
 | ||||
|     override fun getItem(position: Int): Fragment = pages[position].page!! | ||||
| 
 | ||||
|     override fun getCount(): Int = pages.size | ||||
| 
 | ||||
|     override fun getPageTitle(position: Int): CharSequence? = pages[position].title | ||||
| 
 | ||||
|     /** | ||||
|      * Return the Adapter used to display the picture gridview | ||||
|      * @return adapter | ||||
|      */ | ||||
|     val mediaAdapter: ListAdapter? | ||||
|         get() = (((pages[0].page as BookmarkListRootFragment).listFragment) as BookmarkPicturesFragment).getAdapter() | ||||
| } | ||||
|  | @ -1,52 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks.category | ||||
| 
 | ||||
| import androidx.room.Dao | ||||
| import androidx.room.Delete | ||||
| import androidx.room.Insert | ||||
| import androidx.room.OnConflictStrategy | ||||
| import androidx.room.Query | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| 
 | ||||
| /** | ||||
|  * Bookmark categories dao | ||||
|  * | ||||
|  * @constructor Create empty Bookmark categories dao | ||||
|  */ | ||||
| @Dao | ||||
| interface BookmarkCategoriesDao { | ||||
| 
 | ||||
|     /** | ||||
|      * Insert or Delete category bookmark into DB | ||||
|      * | ||||
|      * @param bookmarksCategoryModal | ||||
|      */ | ||||
|     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||
|     suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal) | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Delete category bookmark from DB | ||||
|      * | ||||
|      * @param bookmarksCategoryModal | ||||
|      */ | ||||
|     @Delete | ||||
|     suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal) | ||||
| 
 | ||||
|     /** | ||||
|      * Checks if given category exist in DB | ||||
|      * | ||||
|      * @param categoryName | ||||
|      * @return | ||||
|      */ | ||||
|     @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)") | ||||
|     suspend fun doesExist(categoryName: String): Boolean | ||||
| 
 | ||||
|     /** | ||||
|      * Get all categories | ||||
|      * | ||||
|      * @return | ||||
|      */ | ||||
|     @Query("SELECT * FROM bookmarks_categories") | ||||
|     fun getAllCategories(): Flow<List<BookmarksCategoryModal>> | ||||
| 
 | ||||
| } | ||||
|  | @ -1,143 +0,0 @@ | |||
| package fr.free.nrw.commons.bookmarks.category | ||||
| 
 | ||||
| import android.content.Intent | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.compose.foundation.Image | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.size | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.material3.ListItem | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Surface | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.darkColorScheme | ||||
| import androidx.compose.material3.lightColorScheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.platform.ComposeView | ||||
| import androidx.compose.ui.platform.ViewCompositionStrategy | ||||
| import androidx.compose.ui.res.colorResource | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||
| import dagger.android.support.DaggerFragment | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.category.CategoryDetailsActivity | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| /** | ||||
|  * Tab fragment to show list of bookmarked Categories | ||||
|  */ | ||||
| class BookmarkCategoriesFragment : DaggerFragment() { | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao | ||||
| 
 | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         return ComposeView(requireContext()).apply { | ||||
|             setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | ||||
|             setContent { | ||||
|                 MaterialTheme( | ||||
|                     colorScheme = if (isSystemInDarkTheme()) darkColorScheme( | ||||
|                         primary = colorResource(R.color.primaryDarkColor), | ||||
|                         surface = colorResource(R.color.main_background_dark), | ||||
|                         background = colorResource(R.color.main_background_dark) | ||||
|                     ) else lightColorScheme( | ||||
|                         primary = colorResource(R.color.primaryColor), | ||||
|                         surface = colorResource(R.color.main_background_light), | ||||
|                         background = colorResource(R.color.main_background_light) | ||||
|                     ) | ||||
|                 ) { | ||||
|                     val listOfBookmarks by bookmarkCategoriesDao.getAllCategories() | ||||
|                         .collectAsStateWithLifecycle(initialValue = emptyList()) | ||||
|                     Surface(modifier = Modifier.fillMaxSize()) { | ||||
|                         Box(contentAlignment = Alignment.Center) { | ||||
|                             if (listOfBookmarks.isEmpty()) { | ||||
|                                 Text( | ||||
|                                     text = stringResource(R.string.bookmark_empty), | ||||
|                                     style = MaterialTheme.typography.bodyMedium, | ||||
|                                     color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF) | ||||
|                                     else Color( | ||||
|                                         0x8A000000 | ||||
|                                     ) | ||||
|                                 ) | ||||
|                             } else { | ||||
|                                 LazyColumn(modifier = Modifier.fillMaxSize()) { | ||||
|                                     items(items = listOfBookmarks) { bookmarkItem -> | ||||
|                                         CategoryItem( | ||||
|                                             categoryName = bookmarkItem.categoryName, | ||||
|                                             onClick = { | ||||
|                                                 val categoryDetailsIntent = Intent( | ||||
|                                                     requireContext(), | ||||
|                                                     CategoryDetailsActivity::class.java | ||||
|                                                 ).putExtra("categoryName", it) | ||||
|                                                 startActivity(categoryDetailsIntent) | ||||
|                                             } | ||||
|                                         ) | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     @Composable | ||||
|     fun CategoryItem( | ||||
|         modifier: Modifier = Modifier, | ||||
|         onClick: (String) -> Unit, | ||||
|         categoryName: String | ||||
|     ) { | ||||
|         Row(modifier = modifier.clickable { | ||||
|             onClick(categoryName) | ||||
|         }) { | ||||
|             ListItem( | ||||
|                 leadingContent = { | ||||
|                     Image( | ||||
|                         modifier = Modifier.size(48.dp), | ||||
|                         painter = painterResource(R.drawable.commons), | ||||
|                         contentDescription = null | ||||
|                     ) | ||||
|                 }, | ||||
|                 headlineContent = { | ||||
|                     Text( | ||||
|                         text = categoryName, | ||||
|                         maxLines = 2, | ||||
|                         color = if (isSystemInDarkTheme()) Color.White else Color.Black, | ||||
|                         style = MaterialTheme.typography.bodyMedium, | ||||
|                         fontWeight = FontWeight.SemiBold | ||||
|                     ) | ||||
|                 } | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Preview | ||||
|     @Composable | ||||
|     private fun CategoryItemPreview() { | ||||
|         CategoryItem( | ||||
|             onClick = {}, | ||||
|             categoryName = "Test Category" | ||||
|         ) | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue