mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	Merge branch 'commons-app:main' into main
This commit is contained in:
		
						commit
						35cade3096
					
				
					 1097 changed files with 53050 additions and 43424 deletions
				
			
		
							
								
								
									
										4
									
								
								.github/workflows/android.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/android.yml
									
										
									
									
										vendored
									
									
								
							|  | @ -89,7 +89,7 @@ jobs: | ||||||
|         run: bash ./gradlew assembleBetaDebug --stacktrace |         run: bash ./gradlew assembleBetaDebug --stacktrace | ||||||
| 
 | 
 | ||||||
|       - name: Upload betaDebug APK |       - name: Upload betaDebug APK | ||||||
|         uses: actions/upload-artifact@v3 |         uses: actions/upload-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           name: betaDebugAPK |           name: betaDebugAPK | ||||||
|           path: app/build/outputs/apk/beta/debug/app-*.apk |           path: app/build/outputs/apk/beta/debug/app-*.apk | ||||||
|  | @ -98,7 +98,7 @@ jobs: | ||||||
|         run: bash ./gradlew assembleProdDebug --stacktrace |         run: bash ./gradlew assembleProdDebug --stacktrace | ||||||
| 
 | 
 | ||||||
|       - name: Upload prodDebug APK |       - name: Upload prodDebug APK | ||||||
|         uses: actions/upload-artifact@v3 |         uses: actions/upload-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           name: prodDebugAPK |           name: prodDebugAPK | ||||||
|           path: app/build/outputs/apk/prod/debug/app-*.apk |           path: app/build/outputs/apk/prod/debug/app-*.apk | ||||||
|  |  | ||||||
							
								
								
									
										7
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,16 +1,12 @@ | ||||||
| <component name="InspectionProjectProfileManager"> | <component name="InspectionProjectProfileManager"> | ||||||
|   <profile version="1.0"> |   <profile version="1.0"> | ||||||
|     <option name="myName" value="Project Default" /> |     <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="ClassWithOnlyPrivateConstructors" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||||
|     <inspection_tool class="ConfusingElse" enabled="true" level="WARNING" enabled_by_default="true"> |     <inspection_tool class="ConfusingElse" enabled="true" level="WARNING" enabled_by_default="true"> | ||||||
|       <option name="reportWhenNoStatementFollow" value="true" /> |       <option name="reportWhenNoStatementFollow" value="true" /> | ||||||
|     </inspection_tool> |     </inspection_tool> | ||||||
|     <inspection_tool class="ControlFlowStatementWithoutBraces" enabled="true" level="ERROR" enabled_by_default="true" /> |     <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="ExplicitThis" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> | ||||||
|     <inspection_tool class="FieldMayBeFinal" enabled="true" level="WARNING" enabled_by_default="true" /> |  | ||||||
|     <inspection_tool class="LocalCanBeFinal" 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_VARIABLES" value="true" /> | ||||||
|       <option name="REPORT_PARAMETERS" value="true" /> |       <option name="REPORT_PARAMETERS" value="true" /> | ||||||
|  | @ -25,13 +21,11 @@ | ||||||
|       <option name="ignoreInMatchingInstanceof" value="false" /> |       <option name="ignoreInMatchingInstanceof" value="false" /> | ||||||
|     </inspection_tool> |     </inspection_tool> | ||||||
|     <inspection_tool class="ProblematicWhitespace" enabled="true" level="WARNING" enabled_by_default="true" /> |     <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="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||||
|     <inspection_tool class="RedundantImplements" 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="ignoreSerializable" value="false" /> | ||||||
|       <option name="ignoreCloneable" value="false" /> |       <option name="ignoreCloneable" value="false" /> | ||||||
|     </inspection_tool> |     </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="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||||
|     <inspection_tool class="TypeParameterExtendsFinalClass" 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"> |     <inspection_tool class="UnnecessarilyQualifiedStaticUsage" enabled="true" level="WARNING" enabled_by_default="true"> | ||||||
|  | @ -47,6 +41,5 @@ | ||||||
|     <inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" /> |     <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="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" /> | ||||||
|     <inspection_tool class="UnnecessaryThis" 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> |   </profile> | ||||||
| </component> | </component> | ||||||
							
								
								
									
										111
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										111
									
								
								CHANGELOG.md
									
										
									
									
									
								
							|  | @ -1,5 +1,116 @@ | ||||||
| # Wikimedia Commons for Android | # Wikimedia Commons for Android | ||||||
| 
 | 
 | ||||||
|  | ## 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 | ## v5.0.1 | ||||||
| 
 | 
 | ||||||
| Same as v5.0.0 except this fixes some R8 rules to ensure that the release | Same as v5.0.0 except this fixes some R8 rules to ensure that the release | ||||||
|  |  | ||||||
|  | @ -47,9 +47,27 @@ dependencies { | ||||||
| 
 | 
 | ||||||
|     implementation 'com.jakewharton.timber:timber:4.7.1' |     implementation 'com.jakewharton.timber:timber:4.7.1' | ||||||
|     implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' |     implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' | ||||||
|     implementation 'com.dinuscxj:circleprogressbar:1.1.1' |     implementation "com.google.android.material:material:1.12.0" | ||||||
|     implementation 'com.karumi:dexter:5.0.0' |     implementation 'com.karumi:dexter:5.0.0' | ||||||
|     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' |     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' | ||||||
|  |     implementation 'androidx.compose.ui:ui-tooling-preview' | ||||||
|  |     androidTestImplementation 'androidx.compose.ui:ui-test-junit4' | ||||||
|  | 
 | ||||||
|  |     // Jetpack Compose | ||||||
|  |     def composeBom = platform('androidx.compose:compose-bom:2024.11.00') | ||||||
|  | 
 | ||||||
|  |     implementation "androidx.activity:activity-compose:1.9.3" | ||||||
|  |     implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" | ||||||
|  |     implementation (composeBom) | ||||||
|  |     implementation "androidx.compose.runtime:runtime" | ||||||
|  |     implementation "androidx.compose.ui:ui" | ||||||
|  |     implementation "androidx.compose.ui:ui-viewbinding" | ||||||
|  |     implementation "androidx.compose.ui:ui-graphics" | ||||||
|  |     implementation "androidx.compose.ui:ui-tooling" | ||||||
|  |     implementation "androidx.compose.foundation:foundation" | ||||||
|  |     implementation "androidx.compose.foundation:foundation-layout" | ||||||
|  |     implementation "androidx.compose.material3:material3" | ||||||
|  |     androidTestImplementation(composeBom) | ||||||
| 
 | 
 | ||||||
|     implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" |     implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" | ||||||
|     implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" |     implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" | ||||||
|  | @ -71,6 +89,8 @@ dependencies { | ||||||
|     // Dependency injector |     // Dependency injector | ||||||
|     implementation "com.google.dagger:dagger-android:$DAGGER_VERSION" |     implementation "com.google.dagger:dagger-android:$DAGGER_VERSION" | ||||||
|     implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" |     implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" | ||||||
|  |     debugImplementation 'androidx.compose.ui:ui-tooling' | ||||||
|  |     debugImplementation 'androidx.compose.ui:ui-test-manifest' | ||||||
|     kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" |     kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" | ||||||
|     kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION" |     kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION" | ||||||
|     annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" |     annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" | ||||||
|  | @ -83,6 +103,7 @@ dependencies { | ||||||
|     testImplementation 'org.mockito:mockito-core:5.6.0' |     testImplementation 'org.mockito:mockito-core:5.6.0' | ||||||
|     testImplementation "org.powermock:powermock-module-junit4:2.0.9" |     testImplementation "org.powermock:powermock-module-junit4:2.0.9" | ||||||
|     testImplementation "org.powermock:powermock-api-mockito2:2.0.9" |     testImplementation "org.powermock:powermock-api-mockito2:2.0.9" | ||||||
|  |     testImplementation("io.mockk:mockk:1.13.5") | ||||||
| 
 | 
 | ||||||
|     // Unit testing |     // Unit testing | ||||||
|     testImplementation 'junit:junit:4.13.2' |     testImplementation 'junit:junit:4.13.2' | ||||||
|  | @ -92,7 +113,7 @@ dependencies { | ||||||
|     testImplementation 'androidx.test.ext:junit:1.1.5' |     testImplementation 'androidx.test.ext:junit:1.1.5' | ||||||
|     testImplementation "androidx.test:rules:1.5.0" |     testImplementation "androidx.test:rules:1.5.0" | ||||||
|     testImplementation "com.squareup.okhttp3:mockwebserver:$OKHTTP_VERSION" |     testImplementation "com.squareup.okhttp3:mockwebserver:$OKHTTP_VERSION" | ||||||
|     testImplementation "com.jraska.livedata:testing-ktx:1.1.2" |     testImplementation "com.jraska.livedata:testing-ktx:1.2.0" | ||||||
|     testImplementation "androidx.arch.core:core-testing:2.2.0" |     testImplementation "androidx.arch.core:core-testing:2.2.0" | ||||||
|     testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0" |     testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.0" | ||||||
|     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0" |     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.0" | ||||||
|  | @ -122,7 +143,7 @@ dependencies { | ||||||
|     implementation "androidx.browser:browser:1.3.0" |     implementation "androidx.browser:browser:1.3.0" | ||||||
|     implementation "androidx.cardview:cardview:1.0.0" |     implementation "androidx.cardview:cardview:1.0.0" | ||||||
|     implementation 'androidx.constraintlayout:constraintlayout:1.1.3' |     implementation 'androidx.constraintlayout:constraintlayout:1.1.3' | ||||||
|     implementation "androidx.exifinterface:exifinterface:1.3.2" |     implementation 'androidx.exifinterface:exifinterface:1.3.7' | ||||||
|     implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" |     implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" | ||||||
|     implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' |     implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' | ||||||
| 
 | 
 | ||||||
|  | @ -159,7 +180,7 @@ dependencies { | ||||||
|     kaptTest "androidx.databinding:databinding-compiler:8.0.2" |     kaptTest "androidx.databinding:databinding-compiler:8.0.2" | ||||||
|     kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2" |     kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2" | ||||||
| 
 | 
 | ||||||
|     implementation("io.github.coordinates2country:coordinates2country-android:1.3") {  exclude group: 'com.google.android', module: 'android' } |     implementation("io.github.coordinates2country:coordinates2country-android:1.8") {  exclude group: 'com.google.android', module: 'android' } | ||||||
| 
 | 
 | ||||||
|     //OSMDroid |     //OSMDroid | ||||||
|     implementation ("org.osmdroid:osmdroid-android:$OSMDROID_VERSION") |     implementation ("org.osmdroid:osmdroid-android:$OSMDROID_VERSION") | ||||||
|  | @ -186,17 +207,17 @@ project.gradle.taskGraph.whenReady { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| android { | android { | ||||||
|     compileSdkVersion 33 |     compileSdkVersion 34 | ||||||
| 
 | 
 | ||||||
|     defaultConfig { |     defaultConfig { | ||||||
|         //applicationId 'fr.free.nrw.commons' |         //applicationId 'fr.free.nrw.commons' | ||||||
| 
 | 
 | ||||||
|         versionCode 1039 |         versionCode 1043 | ||||||
|         versionName '5.0.1' |         versionName '5.1.2' | ||||||
|         setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) |         setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) | ||||||
| 
 | 
 | ||||||
|         minSdkVersion 21 |         minSdkVersion 21 | ||||||
|         targetSdkVersion 33 |         targetSdkVersion 34 | ||||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||||
|         testInstrumentationRunnerArguments clearPackageData: 'true' |         testInstrumentationRunnerArguments clearPackageData: 'true' | ||||||
| 
 | 
 | ||||||
|  | @ -211,7 +232,7 @@ android { | ||||||
|             excludes += ['META-INF/androidx.*'] |             excludes += ['META-INF/androidx.*'] | ||||||
|         } |         } | ||||||
|         resources { |         resources { | ||||||
|             excludes += ['META-INF/androidx.*', 'META-INF/proguard/androidx-annotations.pro'] |             excludes += ['META-INF/androidx.*', 'META-INF/proguard/androidx-annotations.pro', '/META-INF/LICENSE.md', '/META-INF/LICENSE-notice.md'] | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -253,11 +274,12 @@ android { | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         debug { |         debug { | ||||||
|             testCoverageEnabled true |  | ||||||
|             minifyEnabled false |             minifyEnabled false | ||||||
|             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' |             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' | ||||||
|             testProguardFile 'test-proguard-rules.txt' |             testProguardFile 'test-proguard-rules.txt' | ||||||
|             versionNameSuffix "-debug-" + getBranchName() |             versionNameSuffix "-debug-" + getBranchName() | ||||||
|  |             enableUnitTestCoverage true | ||||||
|  |             enableAndroidTestCoverage true | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -292,10 +314,12 @@ android { | ||||||
|             buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\"" |             buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\"" | ||||||
|             buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" |             buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" | ||||||
|             buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"" |             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_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", "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", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"" | ||||||
|             buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" |             buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"" | ||||||
|             buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" |             buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" | ||||||
|             buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" |             buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" | ||||||
|  | @ -327,10 +351,12 @@ android { | ||||||
|             buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"" |             buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"" | ||||||
|             buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.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_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_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", "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", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"" | ||||||
|             buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" |             buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"" | ||||||
|             buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" |             buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" | ||||||
|             buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" |             buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" | ||||||
|  | @ -350,17 +376,21 @@ android { | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     compileOptions { |     compileOptions { | ||||||
|         sourceCompatibility JavaVersion.VERSION_1_8 |         sourceCompatibility JavaVersion.VERSION_17 | ||||||
|         targetCompatibility JavaVersion.VERSION_1_8 |         targetCompatibility JavaVersion.VERSION_17 | ||||||
|     } |     } | ||||||
|     kotlinOptions { |     kotlinOptions { | ||||||
|         jvmTarget = "1.8" |         jvmTarget = "17" | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     buildToolsVersion buildToolsVersion |     buildToolsVersion buildToolsVersion | ||||||
| 
 | 
 | ||||||
|     buildFeatures { |     buildFeatures { | ||||||
|         viewBinding true |         viewBinding true | ||||||
|  |         compose true | ||||||
|  |     } | ||||||
|  |     composeOptions { | ||||||
|  |         kotlinCompilerExtensionVersion '1.5.8' | ||||||
|     } |     } | ||||||
|     namespace 'fr.free.nrw.commons' |     namespace 'fr.free.nrw.commons' | ||||||
|     lint { |     lint { | ||||||
|  |  | ||||||
|  | @ -25,7 +25,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class AboutActivityTest { | class AboutActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) |     var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -36,7 +35,8 @@ class AboutActivityTest { | ||||||
|         device.setOrientationNatural() |         device.setOrientationNatural() | ||||||
|         device.freezeRotation() |         device.freezeRotation() | ||||||
|         Intents.init() |         Intents.init() | ||||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) |         Intents | ||||||
|  |             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) |             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -47,11 +47,12 @@ class AboutActivityTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testBuildNumber() { |     fun testBuildNumber() { | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_version)) |         Espresso | ||||||
|  |             .onView(ViewMatchers.withId(R.id.about_version)) | ||||||
|             .check( |             .check( | ||||||
|                 ViewAssertions.matches( |                 ViewAssertions.matches( | ||||||
|                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()) |                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()), | ||||||
|                 ) |                 ), | ||||||
|             ) |             ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -61,8 +62,8 @@ class AboutActivityTest { | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(Urls.WEBSITE_URL) |                 IntentMatchers.hasData(Urls.WEBSITE_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -73,8 +74,8 @@ class AboutActivityTest { | ||||||
|             CoreMatchers.anyOf( |             CoreMatchers.anyOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), |                 IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), | ||||||
|                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME) |                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -84,8 +85,8 @@ class AboutActivityTest { | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL) |                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -95,8 +96,8 @@ class AboutActivityTest { | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL) |                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -104,12 +105,12 @@ class AboutActivityTest { | ||||||
|     fun testLaunchTranslate() { |     fun testLaunchTranslate() { | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) |         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) | ||||||
|         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) |         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) | ||||||
|         val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0] |         val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode") |                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -119,27 +120,30 @@ class AboutActivityTest { | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(Urls.CREDITS_URL) |                 IntentMatchers.hasData(Urls.CREDITS_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testLaunchUserGuide() { |     fun testLaunchUserGuide() { | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) |         Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) | ||||||
|         Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), |         Intents.intended( | ||||||
|             IntentMatchers.hasData(Urls.USER_GUIDE_URL))) |             CoreMatchers.allOf( | ||||||
|  |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|  |                 IntentMatchers.hasData(Urls.USER_GUIDE_URL), | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     @Test |     @Test | ||||||
|     fun testLaunchAboutFaq() { |     fun testLaunchAboutFaq() { | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) |         Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(Urls.FAQ_URL) |                 IntentMatchers.hasData(Urls.FAQ_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -18,12 +18,14 @@ import fr.free.nrw.commons.auth.LoginActivity | ||||||
| import fr.free.nrw.commons.auth.SignupActivity | import fr.free.nrw.commons.auth.SignupActivity | ||||||
| import org.hamcrest.CoreMatchers | import org.hamcrest.CoreMatchers | ||||||
| import org.hamcrest.CoreMatchers.not | import org.hamcrest.CoreMatchers.not | ||||||
| import org.junit.* | import org.junit.After | ||||||
|  | import org.junit.Before | ||||||
|  | import org.junit.Rule | ||||||
|  | import org.junit.Test | ||||||
| import org.junit.runner.RunWith | import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class LoginActivityTest { | class LoginActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule = ActivityTestRule(LoginActivity::class.java) |     var activityRule = ActivityTestRule(LoginActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -49,8 +51,8 @@ class LoginActivityTest { | ||||||
|         Intents.intended( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL) |                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -21,19 +21,22 @@ import fr.free.nrw.commons.kvstore.JsonKvStore | ||||||
| import fr.free.nrw.commons.notification.NotificationActivity | import fr.free.nrw.commons.notification.NotificationActivity | ||||||
| import org.hamcrest.CoreMatchers | import org.hamcrest.CoreMatchers | ||||||
| import org.hamcrest.Matchers | import org.hamcrest.Matchers | ||||||
| import org.junit.* | import org.junit.After | ||||||
|  | import org.junit.Before | ||||||
|  | import org.junit.Rule | ||||||
|  | import org.junit.Test | ||||||
| import org.junit.runner.RunWith | import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @LargeTest | @LargeTest | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class MainActivityTest { | class MainActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) |     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) | ||||||
| 
 | 
 | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( |     var mGrantPermissionRule: GrantPermissionRule = | ||||||
|         "android.permission.ACCESS_FINE_LOCATION" |         GrantPermissionRule.grant( | ||||||
|  |             "android.permission.ACCESS_FINE_LOCATION", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     private val device: UiDevice = |     private val device: UiDevice = | ||||||
|  | @ -48,7 +51,8 @@ class MainActivityTest { | ||||||
|         UITestHelper.loginUser() |         UITestHelper.loginUser() | ||||||
|         UITestHelper.skipWelcome() |         UITestHelper.skipWelcome() | ||||||
|         Intents.init() |         Intents.init() | ||||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) |         Intents | ||||||
|  |             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) |             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||||
|         val context = InstrumentationRegistry.getInstrumentation().targetContext |         val context = InstrumentationRegistry.getInstrumentation().targetContext | ||||||
|         val storeName = context.packageName + "_preferences" |         val storeName = context.packageName + "_preferences" | ||||||
|  | @ -62,33 +66,37 @@ class MainActivityTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testNearby() { |     fun testNearby() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     1 |                         1, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) |         Espresso | ||||||
|  |             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||||
|             .check(matches(ViewMatchers.isDisplayed())) |             .check(matches(ViewMatchers.isDisplayed())) | ||||||
|         UITestHelper.sleep(10000) |         UITestHelper.sleep(10000) | ||||||
|         val actionMenuItemView2 = Espresso.onView( |         val actionMenuItemView2 = | ||||||
|  |             Espresso.onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                 ViewMatchers.withId(R.id.list_sheet), ViewMatchers.withContentDescription("List"), |                     ViewMatchers.withId(R.id.list_sheet), | ||||||
|  |                     ViewMatchers.withContentDescription("List"), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.toolbar), |                             ViewMatchers.withId(R.id.toolbar), | ||||||
|                         1 |                             1, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         actionMenuItemView2.perform(ViewActions.click()) |         actionMenuItemView2.perform(ViewActions.click()) | ||||||
|         UITestHelper.sleep(1000) |         UITestHelper.sleep(1000) | ||||||
|  | @ -96,64 +104,70 @@ class MainActivityTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testExplore() { |     fun testExplore() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     2 |                         2, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) |         Espresso | ||||||
|  |             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||||
|             .check(matches(ViewMatchers.isDisplayed())) |             .check(matches(ViewMatchers.isDisplayed())) | ||||||
|         UITestHelper.sleep(1000) |         UITestHelper.sleep(1000) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testContributions() { |     fun testContributions() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) |         Espresso | ||||||
|  |             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||||
|             .check(matches(ViewMatchers.isDisplayed())) |             .check(matches(ViewMatchers.isDisplayed())) | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     ViewMatchers.withId(R.id.contributionImage), |                     ViewMatchers.withId(R.id.contributionImage), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.contributionsList), |                             ViewMatchers.withId(R.id.contributionsList), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     1 |                         1, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         val actionMenuItemView = Espresso.onView( |         val actionMenuItemView = | ||||||
|  |             Espresso.onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     ViewMatchers.withId(R.id.menu_bookmark_current_image), |                     ViewMatchers.withId(R.id.menu_bookmark_current_image), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.toolbar), |                             ViewMatchers.withId(R.id.toolbar), | ||||||
|                         1 |                             1, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         actionMenuItemView.perform(ViewActions.click()) |         actionMenuItemView.perform(ViewActions.click()) | ||||||
|         UITestHelper.sleep(3000) |         UITestHelper.sleep(3000) | ||||||
|  | @ -161,70 +175,40 @@ class MainActivityTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testBookmarks() { |     fun testBookmarks() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     3 |                         3, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         UITestHelper.sleep(1000) |         UITestHelper.sleep(1000) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testNotifications() { |     fun testNotifications() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     ViewMatchers.withId(R.id.notifications), |                     ViewMatchers.withId(R.id.notifications), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.toolbar), |                             ViewMatchers.withId(R.id.toolbar), | ||||||
|                         1 |                             1, | ||||||
|                         ), |                         ), | ||||||
|                     1 |                         1, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) |         Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) | ||||||
|         Espresso.pressBack() |         Espresso.pressBack() | ||||||
|         UITestHelper.sleep(1000) |         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,7 +4,6 @@ import android.app.Activity | ||||||
| import android.app.Instrumentation | import android.app.Instrumentation | ||||||
| import androidx.test.espresso.Espresso.onView | import androidx.test.espresso.Espresso.onView | ||||||
| import androidx.test.espresso.action.ViewActions | import androidx.test.espresso.action.ViewActions | ||||||
| import androidx.test.espresso.action.ViewActions.swipeRight |  | ||||||
| import androidx.test.espresso.intent.Intents | import androidx.test.espresso.intent.Intents | ||||||
| import androidx.test.espresso.intent.matcher.IntentMatchers | import androidx.test.espresso.intent.matcher.IntentMatchers | ||||||
| import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent | import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent | ||||||
|  | @ -26,7 +25,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class ProfileActivityTest { | class ProfileActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule = IntentsTestRule(LoginActivity::class.java) |     var activityRule = IntentsTestRule(LoginActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -38,7 +36,8 @@ class ProfileActivityTest { | ||||||
|         device.freezeRotation() |         device.freezeRotation() | ||||||
|         UITestHelper.loginUser() |         UITestHelper.loginUser() | ||||||
|         UITestHelper.skipWelcome() |         UITestHelper.skipWelcome() | ||||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) |         Intents | ||||||
|  |             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) |             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -50,20 +49,19 @@ class ProfileActivityTest { | ||||||
|                 childAtPosition( |                 childAtPosition( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         withId(R.id.fragment_main_nav_tab_layout), |                         withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                         0, | ||||||
|                     ), |                     ), | ||||||
|                     4 |                     4, | ||||||
|  |                 ), | ||||||
|  |                 ViewMatchers.isDisplayed(), | ||||||
|             ), |             ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|         ).perform(ViewActions.click()) |         ).perform(ViewActions.click()) | ||||||
|         onView(Matchers.allOf(withId(R.id.more_profile))).perform( |         onView(Matchers.allOf(withId(R.id.more_profile))).perform( | ||||||
|             ViewActions.scrollTo(), |             ViewActions.scrollTo(), | ||||||
|             ViewActions.click() |             ViewActions.click(), | ||||||
|         ) |         ) | ||||||
|         device.swipe(1033, 1346, 531, 1346, 20) |         device.swipe(1033, 1346, 531, 1346, 20) | ||||||
|         UITestHelper.sleep(5000) |         UITestHelper.sleep(5000) | ||||||
|         Intents.intended(hasComponent(ProfileActivity::class.java.name)) |         Intents.intended(hasComponent(ProfileActivity::class.java.name)) | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -9,7 +9,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class ReviewActivityTest { | class ReviewActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) |     var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -17,5 +16,4 @@ class ReviewActivityTest { | ||||||
|     fun orientationChange() { |     fun orientationChange() { | ||||||
|         UITestHelper.changeOrientation(activityRule) |         UITestHelper.changeOrientation(activityRule) | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | @ -16,7 +16,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class SearchActivityTest { | class SearchActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule = ActivityTestRule(SearchActivity::class.java) |     var activityRule = ActivityTestRule(SearchActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -31,20 +30,21 @@ class SearchActivityTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun exploreActivityTest() { |     fun exploreActivityTest() { | ||||||
|         val searchAutoComplete = Espresso.onView( |         val searchAutoComplete = | ||||||
|  |             Espresso.onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     UITestHelper.childAtPosition( |                     UITestHelper.childAtPosition( | ||||||
|                         Matchers.allOf( |                         Matchers.allOf( | ||||||
|                             ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), |                             ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||||
|                             UITestHelper.childAtPosition( |                             UITestHelper.childAtPosition( | ||||||
|                                 ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), |                                 ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||||
|                             1 |                                 1, | ||||||
|                         ) |  | ||||||
|                             ), |                             ), | ||||||
|                     0 |  | ||||||
|                         ), |                         ), | ||||||
|                 ViewMatchers.isDisplayed() |                         0, | ||||||
|             ) |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|  |                 ), | ||||||
|             ) |             ) | ||||||
|         searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) |         searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) | ||||||
|         UITestHelper.sleep(5000) |         UITestHelper.sleep(5000) | ||||||
|  |  | ||||||
|  | @ -22,7 +22,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class SettingsActivityLoggedInTest { | class SettingsActivityLoggedInTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) |     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -35,31 +34,32 @@ class SettingsActivityLoggedInTest { | ||||||
|         device.freezeRotation() |         device.freezeRotation() | ||||||
|         UITestHelper.loginUser() |         UITestHelper.loginUser() | ||||||
|         UITestHelper.skipWelcome() |         UITestHelper.skipWelcome() | ||||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) |         Intents | ||||||
|  |             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) |             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun testSettings() { |     fun testSettings() { | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 Matchers.allOf( |                 Matchers.allOf( | ||||||
|                     ViewMatchers.withContentDescription("More"), |                     ViewMatchers.withContentDescription("More"), | ||||||
|                     UITestHelper.childAtPosition( |                     UITestHelper.childAtPosition( | ||||||
|                         UITestHelper.childAtPosition( |                         UITestHelper.childAtPosition( | ||||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     4 |                         4, | ||||||
|  |                     ), | ||||||
|  |                     ViewMatchers.isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 ViewMatchers.isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ).perform(ViewActions.click()) |             ).perform(ViewActions.click()) | ||||||
|         Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( |         Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( | ||||||
|             ViewActions.scrollTo(), |             ViewActions.scrollTo(), | ||||||
|             ViewActions.click() |             ViewActions.click(), | ||||||
|         ) |         ) | ||||||
|         Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) |         Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) | ||||||
|         UITestHelper.sleep(1000) |         UITestHelper.sleep(1000) | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | @ -23,7 +23,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class SettingsActivityTest { | class SettingsActivityTest { | ||||||
| 
 |  | ||||||
|     private lateinit var defaultKvStore: JsonKvStore |     private lateinit var defaultKvStore: JsonKvStore | ||||||
| 
 | 
 | ||||||
|     @get:Rule |     @get:Rule | ||||||
|  | @ -44,21 +43,23 @@ class SettingsActivityTest { | ||||||
|     fun useAuthorNameTogglesOn() { |     fun useAuthorNameTogglesOn() { | ||||||
|         // Turn on "Use author name" preference if currently off |         // Turn on "Use author name" preference if currently off | ||||||
|         if (!defaultKvStore.getBoolean("useAuthorName", false)) { |         if (!defaultKvStore.getBoolean("useAuthorName", false)) { | ||||||
|             Espresso.onView( |             Espresso | ||||||
|  |                 .onView( | ||||||
|                     allOf( |                     allOf( | ||||||
|                         withId(R.id.recycler_view), |                         withId(R.id.recycler_view), | ||||||
|                     childAtPosition(withId(android.R.id.list_container), 0) |                         childAtPosition(withId(android.R.id.list_container), 0), | ||||||
|                 ) |                     ), | ||||||
|                 ).perform( |                 ).perform( | ||||||
|                 RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()) |                     RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()), | ||||||
|                 ) |                 ) | ||||||
|         } |         } | ||||||
|         // Check authorName preference is enabled |         // Check authorName preference is enabled | ||||||
|         Espresso.onView( |         Espresso | ||||||
|  |             .onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.recycler_view), |                     withId(R.id.recycler_view), | ||||||
|                 childAtPosition(withId(android.R.id.list_container), 0) |                     childAtPosition(withId(android.R.id.list_container), 0), | ||||||
|             ) |                 ), | ||||||
|             ).check(matches(isEnabled())) |             ).check(matches(isEnabled())) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -10,10 +10,13 @@ import androidx.test.espresso.action.ViewActions | ||||||
| import androidx.test.espresso.matcher.ViewMatchers | import androidx.test.espresso.matcher.ViewMatchers | ||||||
| import androidx.test.rule.ActivityTestRule | import androidx.test.rule.ActivityTestRule | ||||||
| import org.apache.commons.lang3.StringUtils | import org.apache.commons.lang3.StringUtils | ||||||
| import org.hamcrest.* | import org.hamcrest.BaseMatcher | ||||||
|  | import org.hamcrest.Description | ||||||
|  | import org.hamcrest.Matcher | ||||||
|  | import org.hamcrest.Matchers | ||||||
|  | import org.hamcrest.TypeSafeMatcher | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| class UITestHelper { | class UITestHelper { | ||||||
|     companion object { |     companion object { | ||||||
|         fun skipWelcome() { |         fun skipWelcome() { | ||||||
|  | @ -30,25 +33,29 @@ class UITestHelper { | ||||||
|         fun skipLogin() { |         fun skipLogin() { | ||||||
|             try { |             try { | ||||||
|                 // Skip Login |                 // Skip Login | ||||||
|                 val htmlTextView = onView( |                 val htmlTextView = | ||||||
|  |                     onView( | ||||||
|                         Matchers.allOf( |                         Matchers.allOf( | ||||||
|                         ViewMatchers.withId(R.id.skip_login), ViewMatchers.withText("Skip"), |                             ViewMatchers.withId(R.id.skip_login), | ||||||
|                         ViewMatchers.isDisplayed() |                             ViewMatchers.withText("Skip"), | ||||||
|                     ) |                             ViewMatchers.isDisplayed(), | ||||||
|  |                         ), | ||||||
|                     ) |                     ) | ||||||
|                 htmlTextView.perform(ViewActions.click()) |                 htmlTextView.perform(ViewActions.click()) | ||||||
| 
 | 
 | ||||||
|                 val appCompatButton = onView( |                 val appCompatButton = | ||||||
|  |                     onView( | ||||||
|                         Matchers.allOf( |                         Matchers.allOf( | ||||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), |                             ViewMatchers.withId(android.R.id.button1), | ||||||
|  |                             ViewMatchers.withText("Yes"), | ||||||
|                             childAtPosition( |                             childAtPosition( | ||||||
|                                 childAtPosition( |                                 childAtPosition( | ||||||
|                                     ViewMatchers.withId(R.id.buttonPanel), |                                     ViewMatchers.withId(R.id.buttonPanel), | ||||||
|                                 0 |                                     0, | ||||||
|  |                                 ), | ||||||
|  |                                 3, | ||||||
|  |                             ), | ||||||
|                         ), |                         ), | ||||||
|                             3 |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                     ) |                     ) | ||||||
|                 appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) |                 appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) | ||||||
|             } catch (ignored: NoMatchingViewException) { |             } catch (ignored: NoMatchingViewException) { | ||||||
|  | @ -62,13 +69,13 @@ class UITestHelper { | ||||||
|                 onView(ViewMatchers.withId(R.id.login_username)) |                 onView(ViewMatchers.withId(R.id.login_username)) | ||||||
|                     .perform( |                     .perform( | ||||||
|                         ViewActions.replaceText(getTestUsername()), |                         ViewActions.replaceText(getTestUsername()), | ||||||
|                         ViewActions.closeSoftKeyboard() |                         ViewActions.closeSoftKeyboard(), | ||||||
|                     ) |                     ) | ||||||
|                 sleep(2000) |                 sleep(2000) | ||||||
|                 onView(ViewMatchers.withId(R.id.login_password)) |                 onView(ViewMatchers.withId(R.id.login_password)) | ||||||
|                     .perform( |                     .perform( | ||||||
|                         ViewActions.replaceText(getTestUserPassword()), |                         ViewActions.replaceText(getTestUserPassword()), | ||||||
|                         ViewActions.closeSoftKeyboard() |                         ViewActions.closeSoftKeyboard(), | ||||||
|                     ) |                     ) | ||||||
|                 sleep(2000) |                 sleep(2000) | ||||||
|                 onView(ViewMatchers.withId(R.id.login_button)) |                 onView(ViewMatchers.withId(R.id.login_button)) | ||||||
|  | @ -76,7 +83,6 @@ class UITestHelper { | ||||||
|                 sleep(10000) |                 sleep(10000) | ||||||
|             } catch (ignored: NoMatchingViewException) { |             } catch (ignored: NoMatchingViewException) { | ||||||
|             } |             } | ||||||
| 
 |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         fun logoutUser() { |         fun logoutUser() { | ||||||
|  | @ -87,36 +93,38 @@ class UITestHelper { | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             childAtPosition( |                             childAtPosition( | ||||||
|                                 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), |                                 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                                 0 |                                 0, | ||||||
|                             ), |                             ), | ||||||
|                             4 |                             4, | ||||||
|  |                         ), | ||||||
|  |                         ViewMatchers.isDisplayed(), | ||||||
|                     ), |                     ), | ||||||
|                         ViewMatchers.isDisplayed() |  | ||||||
|                     ) |  | ||||||
|                 ).perform(ViewActions.click()) |                 ).perform(ViewActions.click()) | ||||||
|                 onView( |                 onView( | ||||||
|                     Matchers.allOf( |                     Matchers.allOf( | ||||||
|                         ViewMatchers.withId(R.id.more_logout), ViewMatchers.withText("Logout"), |                         ViewMatchers.withId(R.id.more_logout), | ||||||
|  |                         ViewMatchers.withText("Logout"), | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             childAtPosition( |                             childAtPosition( | ||||||
|                                 ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), |                                 ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), | ||||||
|                                 0 |                                 0, | ||||||
|  |                             ), | ||||||
|  |                             6, | ||||||
|  |                         ), | ||||||
|                     ), |                     ), | ||||||
|                             6 |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) |                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||||
|                 onView( |                 onView( | ||||||
|                     Matchers.allOf( |                     Matchers.allOf( | ||||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), |                         ViewMatchers.withId(android.R.id.button1), | ||||||
|  |                         ViewMatchers.withText("Yes"), | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             childAtPosition( |                             childAtPosition( | ||||||
|                                 ViewMatchers.withId(R.id.buttonPanel), |                                 ViewMatchers.withId(R.id.buttonPanel), | ||||||
|                                 0 |                                 0, | ||||||
|  |                             ), | ||||||
|  |                             3, | ||||||
|  |                         ), | ||||||
|                     ), |                     ), | ||||||
|                             3 |  | ||||||
|                         ) |  | ||||||
|                     ) |  | ||||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) |                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||||
|                 sleep(5000) |                 sleep(5000) | ||||||
|             } catch (ignored: NoMatchingViewException) { |             } catch (ignored: NoMatchingViewException) { | ||||||
|  | @ -124,9 +132,9 @@ class UITestHelper { | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         fun childAtPosition( |         fun childAtPosition( | ||||||
|             parentMatcher: Matcher<View>, position: Int |             parentMatcher: Matcher<View>, | ||||||
|  |             position: Int, | ||||||
|         ): Matcher<View> { |         ): Matcher<View> { | ||||||
| 
 |  | ||||||
|             return object : TypeSafeMatcher<View>() { |             return object : TypeSafeMatcher<View>() { | ||||||
|                 override fun describeTo(description: Description) { |                 override fun describeTo(description: Description) { | ||||||
|                     description.appendText("Child at position $position in parent ") |                     description.appendText("Child at position $position in parent ") | ||||||
|  | @ -135,8 +143,9 @@ class UITestHelper { | ||||||
| 
 | 
 | ||||||
|                 public override fun matchesSafely(view: View): Boolean { |                 public override fun matchesSafely(view: View): Boolean { | ||||||
|                     val parent = view.parent |                     val parent = view.parent | ||||||
|                     return parent is ViewGroup && parentMatcher.matches(parent) |                     return parent is ViewGroup && | ||||||
|                             && view == parent.getChildAt(position) |                         parentMatcher.matches(parent) && | ||||||
|  |                         view == parent.getChildAt(position) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -154,14 +163,18 @@ class UITestHelper { | ||||||
|             val username = BuildConfig.TEST_USERNAME |             val username = BuildConfig.TEST_USERNAME | ||||||
|             if (StringUtils.isEmpty(username) || username == "null") { |             if (StringUtils.isEmpty(username) || username == "null") { | ||||||
|                 throw NotImplementedError("Configure your beta account's username") |                 throw NotImplementedError("Configure your beta account's username") | ||||||
|             } else return username |             } else { | ||||||
|  |                 return username | ||||||
|  |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         private fun getTestUserPassword(): String { |         private fun getTestUserPassword(): String { | ||||||
|             val password = BuildConfig.TEST_PASSWORD |             val password = BuildConfig.TEST_PASSWORD | ||||||
|             if (StringUtils.isEmpty(password) || password == "null") { |             if (StringUtils.isEmpty(password) || password == "null") { | ||||||
|                 throw NotImplementedError("Configure your beta account's password") |                 throw NotImplementedError("Configure your beta account's password") | ||||||
|             } else return password |             } else { | ||||||
|  |                 return password | ||||||
|  |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) { |         fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) { | ||||||
|  | @ -174,6 +187,7 @@ class UITestHelper { | ||||||
|         fun <T> first(matcher: Matcher<T>): Matcher<T>? { |         fun <T> first(matcher: Matcher<T>): Matcher<T>? { | ||||||
|             return object : BaseMatcher<T>() { |             return object : BaseMatcher<T>() { | ||||||
|                 var isFirst = true |                 var isFirst = true | ||||||
|  | 
 | ||||||
|                 override fun matches(item: Any): Boolean { |                 override fun matches(item: Any): Boolean { | ||||||
|                     if (isFirst && matcher.matches(item)) { |                     if (isFirst && matcher.matches(item)) { | ||||||
|                         isFirst = false |                         isFirst = false | ||||||
|  |  | ||||||
|  | @ -4,7 +4,10 @@ import android.app.Activity | ||||||
| import android.app.Instrumentation | import android.app.Instrumentation | ||||||
| import androidx.recyclerview.widget.RecyclerView | import androidx.recyclerview.widget.RecyclerView | ||||||
| import androidx.test.espresso.Espresso.onView | import androidx.test.espresso.Espresso.onView | ||||||
| import androidx.test.espresso.action.ViewActions.* | import androidx.test.espresso.action.ViewActions.click | ||||||
|  | import androidx.test.espresso.action.ViewActions.closeSoftKeyboard | ||||||
|  | import androidx.test.espresso.action.ViewActions.replaceText | ||||||
|  | import androidx.test.espresso.action.ViewActions.scrollTo | ||||||
| import androidx.test.espresso.contrib.RecyclerViewActions | import androidx.test.espresso.contrib.RecyclerViewActions | ||||||
| import androidx.test.espresso.intent.Intents | import androidx.test.espresso.intent.Intents | ||||||
| import androidx.test.espresso.intent.matcher.IntentMatchers | import androidx.test.espresso.intent.matcher.IntentMatchers | ||||||
|  | @ -15,7 +18,7 @@ import androidx.test.platform.app.InstrumentationRegistry | ||||||
| import androidx.test.rule.ActivityTestRule | import androidx.test.rule.ActivityTestRule | ||||||
| import androidx.test.rule.GrantPermissionRule | import androidx.test.rule.GrantPermissionRule | ||||||
| import androidx.test.uiautomator.UiDevice | 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.UITestHelper.Companion.childAtPosition | ||||||
| import fr.free.nrw.commons.auth.LoginActivity | import fr.free.nrw.commons.auth.LoginActivity | ||||||
| import org.hamcrest.CoreMatchers | import org.hamcrest.CoreMatchers | ||||||
|  | @ -28,7 +31,6 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class UploadCancelledTest { | class UploadCancelledTest { | ||||||
| 
 |  | ||||||
|     @Rule |     @Rule | ||||||
|     @JvmField |     @JvmField | ||||||
|     var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) |     var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) | ||||||
|  | @ -37,7 +39,7 @@ class UploadCancelledTest { | ||||||
|     @JvmField |     @JvmField | ||||||
|     var mGrantPermissionRule: GrantPermissionRule = |     var mGrantPermissionRule: GrantPermissionRule = | ||||||
|         GrantPermissionRule.grant( |         GrantPermissionRule.grant( | ||||||
|             "android.permission.WRITE_EXTERNAL_STORAGE" |             "android.permission.WRITE_EXTERNAL_STORAGE", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     private val device: UiDevice = |     private val device: UiDevice = | ||||||
|  | @ -48,14 +50,14 @@ class UploadCancelledTest { | ||||||
|         try { |         try { | ||||||
|             Intents.init() |             Intents.init() | ||||||
|         } catch (ex: IllegalStateException) { |         } catch (ex: IllegalStateException) { | ||||||
| 
 |  | ||||||
|         } |         } | ||||||
|         device.unfreezeRotation() |         device.unfreezeRotation() | ||||||
|         device.setOrientationNatural() |         device.setOrientationNatural() | ||||||
|         device.freezeRotation() |         device.freezeRotation() | ||||||
|         UITestHelper.loginUser() |         UITestHelper.loginUser() | ||||||
|         UITestHelper.skipWelcome() |         UITestHelper.skipWelcome() | ||||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) |         Intents | ||||||
|  |             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) |             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -64,129 +66,136 @@ class UploadCancelledTest { | ||||||
|         try { |         try { | ||||||
|             Intents.release() |             Intents.release() | ||||||
|         } catch (ex: IllegalStateException) { |         } catch (ex: IllegalStateException) { | ||||||
| 
 |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun uploadCancelledAfterLocationPickedTest() { |     fun uploadCancelledAfterLocationPickedTest() { | ||||||
| 
 |         val bottomNavigationItemView = | ||||||
|         val bottomNavigationItemView = onView( |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             withId(R.id.fragment_main_nav_tab_layout), |                             withId(R.id.fragment_main_nav_tab_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     1 |                         1, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         bottomNavigationItemView.perform(click()) |         bottomNavigationItemView.perform(click()) | ||||||
| 
 | 
 | ||||||
|         UITestHelper.sleep(12000) |         UITestHelper.sleep(12000) | ||||||
| 
 | 
 | ||||||
|         val actionMenuItemView = onView( |         val actionMenuItemView = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.list_sheet), |                     withId(R.id.list_sheet), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             withId(R.id.toolbar), |                             withId(R.id.toolbar), | ||||||
|                         1 |                             1, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         actionMenuItemView.perform(click()) |         actionMenuItemView.perform(click()) | ||||||
| 
 | 
 | ||||||
|         val recyclerView = onView( |         val recyclerView = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.rv_nearby_list), |                     withId(R.id.rv_nearby_list), | ||||||
|             ) |                 ), | ||||||
|             ) |             ) | ||||||
|         recyclerView.perform( |         recyclerView.perform( | ||||||
|             RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( |             RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( | ||||||
|                 0, |                 0, | ||||||
|                 click() |                 click(), | ||||||
|             ) |             ), | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         val linearLayout3 = onView( |         val linearLayout3 = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.cameraButton), |                     withId(R.id.cameraButton), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         allOf( |                         allOf( | ||||||
|                             withId(R.id.nearby_button_layout), |                             withId(R.id.nearby_button_layout), | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         linearLayout3.perform(click()) |         linearLayout3.perform(click()) | ||||||
| 
 | 
 | ||||||
|         val pasteSensitiveTextInputEditText = onView( |         val pasteSensitiveTextInputEditText = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.caption_item_edit_text), |                     withId(R.id.caption_item_edit_text), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             withId(R.id.caption_item_edit_text_input_layout), |                             withId(R.id.caption_item_edit_text_input_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) |         pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) | ||||||
| 
 | 
 | ||||||
|         val pasteSensitiveTextInputEditText2 = onView( |         val pasteSensitiveTextInputEditText2 = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.description_item_edit_text), |                     withId(R.id.description_item_edit_text), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             withId(R.id.description_item_edit_text_input_layout), |                             withId(R.id.description_item_edit_text_input_layout), | ||||||
|                         0 |                             0, | ||||||
|                         ), |                         ), | ||||||
|                     0 |                         0, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) |         pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) | ||||||
| 
 | 
 | ||||||
|         val appCompatButton2 = onView( |         val appCompatButton2 = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.btn_next), |                     withId(R.id.btn_next), | ||||||
|                     childAtPosition( |                     childAtPosition( | ||||||
|                         childAtPosition( |                         childAtPosition( | ||||||
|                             withId(R.id.ll_container_media_detail), |                             withId(R.id.ll_container_media_detail), | ||||||
|                         2 |                             2, | ||||||
|                         ), |                         ), | ||||||
|                     1 |                         1, | ||||||
|  |                     ), | ||||||
|  |                     isDisplayed(), | ||||||
|                 ), |                 ), | ||||||
|                 isDisplayed() |  | ||||||
|             ) |  | ||||||
|             ) |             ) | ||||||
|         appCompatButton2.perform(click()) |         appCompatButton2.perform(click()) | ||||||
| 
 | 
 | ||||||
|         val appCompatButton3 = onView( |         val appCompatButton3 = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(android.R.id.button1), |                     withId(android.R.id.button1), | ||||||
|             ) |                 ), | ||||||
|             ) |             ) | ||||||
|         appCompatButton3.perform(scrollTo(), click()) |         appCompatButton3.perform(scrollTo(), click()) | ||||||
| 
 | 
 | ||||||
|         Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) |         Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) | ||||||
| 
 | 
 | ||||||
|         val floatingActionButton3 = onView( |         val floatingActionButton3 = | ||||||
|  |             onView( | ||||||
|                 allOf( |                 allOf( | ||||||
|                     withId(R.id.location_chosen_button), |                     withId(R.id.location_chosen_button), | ||||||
|                 isDisplayed() |                     isDisplayed(), | ||||||
|             ) |                 ), | ||||||
|             ) |             ) | ||||||
|         UITestHelper.sleep(2000) |         UITestHelper.sleep(2000) | ||||||
|         floatingActionButton3.perform(click()) |         floatingActionButton3.perform(click()) | ||||||
|  |  | ||||||
|  | @ -19,7 +19,10 @@ import androidx.test.espresso.intent.Intents.intended | ||||||
| import androidx.test.espresso.intent.Intents.intending | import androidx.test.espresso.intent.Intents.intending | ||||||
| import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction | import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction | ||||||
| import androidx.test.espresso.intent.matcher.IntentMatchers.hasType | import androidx.test.espresso.intent.matcher.IntentMatchers.hasType | ||||||
| import androidx.test.espresso.matcher.ViewMatchers.* | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed | ||||||
|  | import androidx.test.espresso.matcher.ViewMatchers.withId | ||||||
|  | import androidx.test.espresso.matcher.ViewMatchers.withParent | ||||||
|  | import androidx.test.espresso.matcher.ViewMatchers.withText | ||||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||||
| import androidx.test.filters.LargeTest | import androidx.test.filters.LargeTest | ||||||
| import androidx.test.rule.ActivityTestRule | import androidx.test.rule.ActivityTestRule | ||||||
|  | @ -29,21 +32,29 @@ import fr.free.nrw.commons.upload.UploadMediaDetailAdapter | ||||||
| import fr.free.nrw.commons.util.MyViewAction | import fr.free.nrw.commons.util.MyViewAction | ||||||
| import fr.free.nrw.commons.utils.ConfigUtils | import fr.free.nrw.commons.utils.ConfigUtils | ||||||
| import org.hamcrest.core.AllOf.allOf | import org.hamcrest.core.AllOf.allOf | ||||||
| import org.junit.* | import org.junit.After | ||||||
|  | import org.junit.Before | ||||||
|  | import org.junit.Ignore | ||||||
|  | import org.junit.Rule | ||||||
|  | import org.junit.Test | ||||||
| import org.junit.runner.RunWith | import org.junit.runner.RunWith | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import java.io.File | import java.io.File | ||||||
| import java.io.FileOutputStream | import java.io.FileOutputStream | ||||||
| import java.io.IOException | import java.io.IOException | ||||||
| import java.text.SimpleDateFormat | import java.text.SimpleDateFormat | ||||||
| import java.util.* | import java.util.Date | ||||||
|  | import java.util.Random | ||||||
| 
 | 
 | ||||||
| @LargeTest | @LargeTest | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class UploadTest { | class UploadTest { | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE, |     var permissionRule = | ||||||
|             Manifest.permission.ACCESS_FINE_LOCATION)!! |         GrantPermissionRule.grant( | ||||||
|  |             Manifest.permission.WRITE_EXTERNAL_STORAGE, | ||||||
|  |             Manifest.permission.ACCESS_FINE_LOCATION, | ||||||
|  |         )!! | ||||||
| 
 | 
 | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule = ActivityTestRule(LoginActivity::class.java) |     var activityRule = ActivityTestRule(LoginActivity::class.java) | ||||||
|  | @ -61,7 +72,6 @@ class UploadTest { | ||||||
|         try { |         try { | ||||||
|             Intents.init() |             Intents.init() | ||||||
|         } catch (ex: IllegalStateException) { |         } catch (ex: IllegalStateException) { | ||||||
| 
 |  | ||||||
|         } |         } | ||||||
|         UITestHelper.loginUser() |         UITestHelper.loginUser() | ||||||
|         UITestHelper.skipWelcome() |         UITestHelper.skipWelcome() | ||||||
|  | @ -99,7 +109,6 @@ class UploadTest { | ||||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.description_item_edit_text))) |         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))) |         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||||
|             .perform(click()) |             .perform(click()) | ||||||
| 
 | 
 | ||||||
|  | @ -131,7 +140,8 @@ class UploadTest { | ||||||
| 
 | 
 | ||||||
|         UITestHelper.sleep(10000) |         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" |                 commonsFileName.replace(' ', '_') + ".jpg" | ||||||
|         Timber.i("File should be uploaded to $fileUrl") |         Timber.i("File should be uploaded to $fileUrl") | ||||||
|     } |     } | ||||||
|  | @ -200,7 +210,8 @@ class UploadTest { | ||||||
| 
 | 
 | ||||||
|         UITestHelper.sleep(10000) |         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" |                 commonsFileName.replace(' ', '_') + ".jpg" | ||||||
|         Timber.i("File should be uploaded to $fileUrl") |         Timber.i("File should be uploaded to $fileUrl") | ||||||
|     } |     } | ||||||
|  | @ -231,16 +242,22 @@ class UploadTest { | ||||||
| 
 | 
 | ||||||
|         onView(withId(R.id.rv_descriptions)).perform( |         onView(withId(R.id.rv_descriptions)).perform( | ||||||
|             RecyclerViewActions |             RecyclerViewActions | ||||||
|                         .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(0, |                 .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>( | ||||||
|                                 MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"))) |                     0, | ||||||
|  |                     MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"), | ||||||
|  |                 ), | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         onView(withId(R.id.btn_add)) |         onView(withId(R.id.btn_add)) | ||||||
|             .perform(click()) |             .perform(click()) | ||||||
| 
 | 
 | ||||||
|         onView(withId(R.id.rv_descriptions)).perform( |         onView(withId(R.id.rv_descriptions)).perform( | ||||||
|             RecyclerViewActions |             RecyclerViewActions | ||||||
|                         .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(1, |                 .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>( | ||||||
|                                 MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"))) |                     1, | ||||||
|  |                     MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"), | ||||||
|  |                 ), | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) |         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||||
|             .perform(click()) |             .perform(click()) | ||||||
|  | @ -273,7 +290,8 @@ class UploadTest { | ||||||
| 
 | 
 | ||||||
|         UITestHelper.sleep(10000) |         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" |                 commonsFileName.replace(' ', '_') + ".jpg" | ||||||
|         Timber.i("File should be uploaded to $fileUrl") |         Timber.i("File should be uploaded to $fileUrl") | ||||||
|     } |     } | ||||||
|  | @ -306,7 +324,6 @@ class UploadTest { | ||||||
|             } catch (e: IOException) { |             } catch (e: IOException) { | ||||||
|                 e.printStackTrace() |                 e.printStackTrace() | ||||||
|             } |             } | ||||||
| 
 |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,7 +3,6 @@ package fr.free.nrw.commons | ||||||
| import androidx.test.espresso.Espresso.onView | import androidx.test.espresso.Espresso.onView | ||||||
| import androidx.test.espresso.action.ViewActions | import androidx.test.espresso.action.ViewActions | ||||||
| import androidx.test.espresso.assertion.ViewAssertions.matches | 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.isDisplayed | ||||||
| import androidx.test.espresso.matcher.ViewMatchers.withId | import androidx.test.espresso.matcher.ViewMatchers.withId | ||||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||||
|  | @ -18,11 +17,12 @@ import org.junit.Before | ||||||
| import org.junit.Rule | import org.junit.Rule | ||||||
| import org.junit.Test | import org.junit.Test | ||||||
| import org.junit.runner.RunWith | import org.junit.runner.RunWith | ||||||
|  | import org.hamcrest.MatcherAssert.assertThat | ||||||
|  | import org.hamcrest.CoreMatchers.equalTo | ||||||
| 
 | 
 | ||||||
| @LargeTest | @LargeTest | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class WelcomeActivityTest { | class WelcomeActivityTest { | ||||||
| 
 |  | ||||||
|     @get:Rule |     @get:Rule | ||||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) |     var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) | ||||||
| 
 | 
 | ||||||
|  | @ -61,7 +61,7 @@ class WelcomeActivityTest { | ||||||
|                 .perform(ViewActions.click()) |                 .perform(ViewActions.click()) | ||||||
|             onView(withId(R.id.finishTutorialButton)) |             onView(withId(R.id.finishTutorialButton)) | ||||||
|                 .perform(ViewActions.click()) |                 .perform(ViewActions.click()) | ||||||
|             assert(activityRule.activity.isDestroyed) |             assertThat(activityRule.activity.isDestroyed, equalTo(true)) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -71,10 +71,10 @@ class WelcomeActivityTest { | ||||||
|             .perform(ViewActions.click()) |             .perform(ViewActions.click()) | ||||||
|         onView(withId(R.id.welcomePager)) |         onView(withId(R.id.welcomePager)) | ||||||
|             .perform(ViewActions.swipeLeft()) |             .perform(ViewActions.swipeLeft()) | ||||||
|         assert(true) |         assertThat(true, equalTo(true)) | ||||||
|         onView(withId(R.id.welcomePager)) |         onView(withId(R.id.welcomePager)) | ||||||
|             .perform(ViewActions.swipeRight()) |             .perform(ViewActions.swipeRight()) | ||||||
|         assert(true) |         assertThat(true, equalTo(true)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|  | @ -86,13 +86,13 @@ class WelcomeActivityTest { | ||||||
|             .perform(ViewActions.swipeLeft()) |             .perform(ViewActions.swipeLeft()) | ||||||
|             .perform(ViewActions.swipeLeft()) |             .perform(ViewActions.swipeLeft()) | ||||||
|             .perform(ViewActions.swipeLeft()) |             .perform(ViewActions.swipeLeft()) | ||||||
|         assert(true) |         assertThat(true, equalTo(true)) | ||||||
|         onView(withId(R.id.welcomePager)) |         onView(withId(R.id.welcomePager)) | ||||||
|             .perform(ViewActions.swipeRight()) |             .perform(ViewActions.swipeRight()) | ||||||
|             .perform(ViewActions.swipeRight()) |             .perform(ViewActions.swipeRight()) | ||||||
|             .perform(ViewActions.swipeRight()) |             .perform(ViewActions.swipeRight()) | ||||||
|             .perform(ViewActions.swipeRight()) |             .perform(ViewActions.swipeRight()) | ||||||
|         assert(true) |         assertThat(true, equalTo(true)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|  | @ -103,10 +103,10 @@ class WelcomeActivityTest { | ||||||
|             if (viewPager.currentItem == 3) { |             if (viewPager.currentItem == 3) { | ||||||
|                 onView(withId(R.id.welcomePager)) |                 onView(withId(R.id.welcomePager)) | ||||||
|                     .perform(ViewActions.swipeLeft()) |                     .perform(ViewActions.swipeLeft()) | ||||||
|                 assert(true) |                 assertThat(true, equalTo(true)) | ||||||
|                 onView(withId(R.id.welcomePager)) |                 onView(withId(R.id.welcomePager)) | ||||||
|                     .perform(ViewActions.swipeRight()) |                     .perform(ViewActions.swipeRight()) | ||||||
|                 assert(false) |                 assertThat(true, equalTo(true)) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | @ -121,7 +121,7 @@ class WelcomeActivityTest { | ||||||
|                     .perform(ViewActions.click()) |                     .perform(ViewActions.click()) | ||||||
|                 onView(withId(R.id.finishTutorialButton)) |                 onView(withId(R.id.finishTutorialButton)) | ||||||
|                     .perform(ViewActions.click()) |                     .perform(ViewActions.click()) | ||||||
|                 assert(activityRule.activity.isDestroyed) |                 assertThat(activityRule.activity.isDestroyed, equalTo(true)) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -11,21 +11,24 @@ import org.junit.runner.RunWith | ||||||
| 
 | 
 | ||||||
| @RunWith(AndroidJUnit4::class) | @RunWith(AndroidJUnit4::class) | ||||||
| class PasteSensitiveTextInputEditTextTest { | class PasteSensitiveTextInputEditTextTest { | ||||||
| 
 |  | ||||||
|     private var context: Context? = null |     private var context: Context? = null | ||||||
|     private var textView: PasteSensitiveTextInputEditText? = null |     private var textView: PasteSensitiveTextInputEditText? = null | ||||||
| 
 | 
 | ||||||
|     @Before |     @Before | ||||||
|     fun setup() { |     fun setup() { | ||||||
|         context = ApplicationProvider.getApplicationContext() |         context = ApplicationProvider.getApplicationContext() | ||||||
|         textView = PasteSensitiveTextInputEditText(context) |         textView = PasteSensitiveTextInputEditText(context!!) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // this test has no real value, just % for test code coverage |     // this test has no real value, just % for test code coverage | ||||||
|     @Test |     @Test | ||||||
|     fun extractFormattingAttributeSet() { |     fun extractFormattingAttributeSet() { | ||||||
|         val methodExtractFormattingAttribute = textView!!.javaClass.getDeclaredMethod( |         val methodExtractFormattingAttribute = | ||||||
|             "extractFormattingAttribute", Context::class.java, AttributeSet::class.java) |             textView!!.javaClass.getDeclaredMethod( | ||||||
|  |                 "extractFormattingAttribute", | ||||||
|  |                 Context::class.java, | ||||||
|  |                 AttributeSet::class.java, | ||||||
|  |             ) | ||||||
|         methodExtractFormattingAttribute.isAccessible = true |         methodExtractFormattingAttribute.isAccessible = true | ||||||
|         methodExtractFormattingAttribute.invoke(textView, context, null) |         methodExtractFormattingAttribute.invoke(textView, context, null) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -9,56 +9,58 @@ import org.hamcrest.Matcher | ||||||
| 
 | 
 | ||||||
| class MyViewAction { | class MyViewAction { | ||||||
|     companion object { |     companion object { | ||||||
|         fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction { |         fun typeTextInChildViewWithId( | ||||||
|             return object : ViewAction { |             id: Int, | ||||||
|                 override fun getConstraints(): Matcher<View>? { |             textToBeTyped: String, | ||||||
|                     return null |         ): ViewAction = | ||||||
|                 } |             object : ViewAction { | ||||||
|  |                 override fun getConstraints(): Matcher<View>? = null | ||||||
| 
 | 
 | ||||||
|                 override fun getDescription(): String { |                 override fun getDescription(): String = "Click on a child view with specified id." | ||||||
|                     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 |                     val v = view.findViewById<View>(id) as EditText | ||||||
|                     v.setText(textToBeTyped) |                     v.setText(textToBeTyped) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction { |         fun selectSpinnerItemInChildViewWithId( | ||||||
|             return object : ViewAction { |             id: Int, | ||||||
|                 override fun getConstraints(): Matcher<View>? { |             position: Int, | ||||||
|                     return null |         ): ViewAction = | ||||||
|                 } |             object : ViewAction { | ||||||
|  |                 override fun getConstraints(): Matcher<View>? = null | ||||||
| 
 | 
 | ||||||
|                 override fun getDescription(): String { |                 override fun getDescription(): String = "Click on a child view with specified id." | ||||||
|                     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 |                     val v = view.findViewById<View>(id) as AppCompatSpinner | ||||||
|                     v.setSelection(position) |                     v.setSelection(position) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         fun clickItemWithId(id: Int, position: Int): ViewAction { |         fun clickItemWithId( | ||||||
|             return object : ViewAction { |             id: Int, | ||||||
|                 override fun getConstraints(): Matcher<View>? { |             position: Int, | ||||||
|                     return null |         ): ViewAction = | ||||||
|                 } |             object : ViewAction { | ||||||
|  |                 override fun getConstraints(): Matcher<View>? = null | ||||||
| 
 | 
 | ||||||
|                 override fun getDescription(): String { |                 override fun getDescription(): String = "Click on a child view with specified id." | ||||||
|                     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 |                     val v = view.findViewById<View>(id) as View | ||||||
|                     v.performClick() |                     v.performClick() | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  | @ -1,74 +1,90 @@ | ||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|   xmlns:tools="http://schemas.android.com/tools"> |   xmlns:tools="http://schemas.android.com/tools"> | ||||||
|  | 
 | ||||||
|   <uses-permission android:name="android.permission.INTERNET" /> |   <uses-permission android:name="android.permission.INTERNET" /> | ||||||
|     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |   <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_SETTINGS" /> | ||||||
|   <uses-permission android:name="android.permission.READ_SYNC_STATS" /> |   <uses-permission android:name="android.permission.READ_SYNC_STATS" /> | ||||||
|   <uses-permission android:name="android.permission.REORDER_TASKS" /> |   <uses-permission android:name="android.permission.REORDER_TASKS" /> | ||||||
|   <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> |   <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> | ||||||
|     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |   <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.ACCESS_FINE_LOCATION" /> | ||||||
|   <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /> |   <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /> | ||||||
|     <uses-permission android:name="android.permission.GET_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.USE_CREDENTIALS" /> | ||||||
|   <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /> |   <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /> | ||||||
|   <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> |   <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> | ||||||
|     <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> |   <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="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" /> | ||||||
|   <uses-permission android:name="android.permission.SET_WALLPAPER" /> |   <uses-permission android:name="android.permission.SET_WALLPAPER" /> | ||||||
|   <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |   <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||||
|   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/> |   <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> |   <queries> | ||||||
|  | 
 | ||||||
|     <!-- Browser --> |     <!-- Browser --> | ||||||
|     <intent> |     <intent> | ||||||
|       <action android:name="android.intent.action.VIEW" /> |       <action android:name="android.intent.action.VIEW" /> | ||||||
|  | 
 | ||||||
|       <category android:name="android.intent.category.BROWSABLE" /> |       <category android:name="android.intent.category.BROWSABLE" /> | ||||||
|  | 
 | ||||||
|       <data android:scheme="https" /> |       <data android:scheme="https" /> | ||||||
|     </intent> |     </intent> | ||||||
|     <!-- Google Maps --> |     <!-- Google Maps --> | ||||||
|     <package android:name="com.google.android.apps.maps" /> |     <package android:name="com.google.android.apps.maps" /> | ||||||
|     </queries> |   </queries> <!-- Needed only if your app targets Android 5.0 (API level 21) or higher. --> | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     <!-- Needed only if your app targets Android 5.0 (API level 21) or higher. --> |  | ||||||
|   <uses-feature android:name="android.hardware.location.gps" /> |   <uses-feature android:name="android.hardware.location.gps" /> | ||||||
| 
 | 
 | ||||||
|   <application |   <application | ||||||
|     android:name=".CommonsApplication" |     android:name=".CommonsApplication" | ||||||
|  |     android:appComponentFactory="commons" | ||||||
|     android:icon="@mipmap/ic_launcher" |     android:icon="@mipmap/ic_launcher" | ||||||
|     android:label="@string/app_name" |     android:label="@string/app_name" | ||||||
|         android:theme="@style/LightAppTheme" |  | ||||||
|     android:largeHeap="true" |     android:largeHeap="true" | ||||||
|         android:supportsRtl="true" |  | ||||||
|         tools:replace="android:appComponentFactory" |  | ||||||
|         android:appComponentFactory="commons" |  | ||||||
|     android:requestLegacyExternalStorage="true" |     android:requestLegacyExternalStorage="true" | ||||||
|         tools:ignore="GoogleAppIndexingWarning"> |     android:supportsRtl="true" | ||||||
| 
 |     android:theme="@style/LightAppTheme" | ||||||
|  |     tools:ignore="GoogleAppIndexingWarning" | ||||||
|  |     tools:replace="android:appComponentFactory"> | ||||||
|  |     <activity | ||||||
|  |       android:name=".activity.SingleWebViewActivity" | ||||||
|  |       android:exported="false" | ||||||
|  |       android:label="@string/title_activity_single_web_view" /> | ||||||
|  |     <activity | ||||||
|  |       android:name=".nearby.WikidataFeedback" | ||||||
|  |       android:exported="false" /> | ||||||
|  |     <activity | ||||||
|  |       android:name=".upload.UploadProgressActivity" | ||||||
|  |       android:exported="false" /> | ||||||
|     <activity |     <activity | ||||||
|           android:theme="@style/EditActivityTheme" |  | ||||||
|       android:name=".description.DescriptionEditActivity" |       android:name=".description.DescriptionEditActivity" | ||||||
|             android:exported="true" /> |       android:exported="true" | ||||||
| 
 |       android:theme="@style/EditActivityTheme" /> | ||||||
|     <activity |     <activity | ||||||
|       android:name=".edit.EditActivity" |       android:name=".edit.EditActivity" | ||||||
|       android:exported="false" /> |       android:exported="false" /> | ||||||
| 
 |     <activity | ||||||
|         <activity android:name="org.acra.dialog.CrashReportDialog" |       android:name="org.acra.dialog.CrashReportDialog" | ||||||
|             android:process=":acra" |  | ||||||
|             android:launchMode="singleInstance" |  | ||||||
|       android:excludeFromRecents="true" |       android:excludeFromRecents="true" | ||||||
|             android:finishOnTaskLaunch="true" /> |       android:finishOnTaskLaunch="true" | ||||||
| 
 |       android:launchMode="singleInstance" | ||||||
|  |       android:process=":acra" /> | ||||||
|     <activity |     <activity | ||||||
|       android:name=".media.ZoomableActivity" |       android:name=".media.ZoomableActivity" | ||||||
|             android:label="Zoomable Activity" |  | ||||||
|       android:configChanges="screenSize|keyboard|orientation" |       android:configChanges="screenSize|keyboard|orientation" | ||||||
|  |       android:label="Zoomable Activity" | ||||||
|       android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" /> |       android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" /> | ||||||
| 
 |     <activity | ||||||
|         <activity android:name=".auth.LoginActivity" |       android:name=".auth.LoginActivity" | ||||||
|       android:exported="true"> |       android:exported="true"> | ||||||
|       <intent-filter> |       <intent-filter> | ||||||
|         <category android:name="android.intent.category.LAUNCHER" /> |         <category android:name="android.intent.category.LAUNCHER" /> | ||||||
|  | @ -76,21 +92,18 @@ | ||||||
|         <action android:name="android.intent.action.MAIN" /> |         <action android:name="android.intent.action.MAIN" /> | ||||||
|       </intent-filter> |       </intent-filter> | ||||||
| 
 | 
 | ||||||
|             <meta-data android:name="android.app.shortcuts" |       <meta-data | ||||||
|  |         android:name="android.app.shortcuts" | ||||||
|         android:resource="@xml/shortcuts" /> |         android:resource="@xml/shortcuts" /> | ||||||
| 
 |  | ||||||
|     </activity> |     </activity> | ||||||
|     <activity android:name=".WelcomeActivity" /> |     <activity android:name=".WelcomeActivity" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|             android:hardwareAccelerated="false" |  | ||||||
|       android:name=".upload.UploadActivity" |       android:name=".upload.UploadActivity" | ||||||
|             android:exported="true" |  | ||||||
|       android:configChanges="orientation|screenSize|keyboard" |       android:configChanges="orientation|screenSize|keyboard" | ||||||
|  |       android:exported="true" | ||||||
|  |       android:hardwareAccelerated="false" | ||||||
|       android:icon="@mipmap/ic_launcher" |       android:icon="@mipmap/ic_launcher" | ||||||
|             android:label="@string/app_name" |       android:windowSoftInputMode="adjustResize"> | ||||||
|             android:windowSoftInputMode="adjustResize" |  | ||||||
|             > |  | ||||||
|       <intent-filter android:label="@string/intent_share_upload_label"> |       <intent-filter android:label="@string/intent_share_upload_label"> | ||||||
|         <action android:name="android.intent.action.SEND" /> |         <action android:name="android.intent.action.SEND" /> | ||||||
| 
 | 
 | ||||||
|  | @ -110,9 +123,9 @@ | ||||||
|     </activity> |     </activity> | ||||||
|     <activity |     <activity | ||||||
|       android:name=".contributions.MainActivity" |       android:name=".contributions.MainActivity" | ||||||
|  |       android:configChanges="screenSize|keyboard|orientation" | ||||||
|       android:icon="@mipmap/ic_launcher" |       android:icon="@mipmap/ic_launcher" | ||||||
|             android:label="@string/app_name" |       /> | ||||||
|             android:configChanges="screenSize|keyboard|orientation" /> |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".settings.SettingsActivity" |       android:name=".settings.SettingsActivity" | ||||||
|       android:label="@string/title_activity_settings" /> |       android:label="@string/title_activity_settings" /> | ||||||
|  | @ -120,59 +133,49 @@ | ||||||
|       android:name=".AboutActivity" |       android:name=".AboutActivity" | ||||||
|       android:label="@string/title_activity_about" |       android:label="@string/title_activity_about" | ||||||
|       android:parentActivityName=".contributions.MainActivity" /> |       android:parentActivityName=".contributions.MainActivity" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".auth.SignupActivity" |       android:name=".auth.SignupActivity" | ||||||
|       android:configChanges="orientation|screenLayout|screenSize" |       android:configChanges="orientation|screenLayout|screenSize" | ||||||
|       android:label="@string/title_activity_signup" /> |       android:label="@string/title_activity_signup" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".notification.NotificationActivity" |       android:name=".notification.NotificationActivity" | ||||||
|       android:label="@string/navigation_item_notification" /> |       android:label="@string/navigation_item_notification" /> | ||||||
| 
 |     <activity | ||||||
|         <activity android:name=".quiz.QuizActivity" |       android:name=".quiz.QuizActivity" | ||||||
|       android:label="@string/quiz" /> |       android:label="@string/quiz" /> | ||||||
| 
 |     <activity | ||||||
|         <activity android:name=".quiz.QuizResultActivity" |       android:name=".quiz.QuizResultActivity" | ||||||
|       android:label="@string/result" /> |       android:label="@string/result" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".customselector.ui.selector.CustomSelectorActivity" |       android:name=".customselector.ui.selector.CustomSelectorActivity" | ||||||
|             android:label="@string/title_activity_custom_selector" |  | ||||||
|       android:configChanges="screenSize|keyboard|orientation" |       android:configChanges="screenSize|keyboard|orientation" | ||||||
|  |       android:label="@string/title_activity_custom_selector" | ||||||
|       android:parentActivityName=".contributions.MainActivity" /> |       android:parentActivityName=".contributions.MainActivity" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".category.CategoryDetailsActivity" |       android:name=".category.CategoryDetailsActivity" | ||||||
|             android:label="@string/title_activity_featured_images" |  | ||||||
|       android:configChanges="screenSize|keyboard|orientation" |       android:configChanges="screenSize|keyboard|orientation" | ||||||
|  |       android:label="@string/title_activity_featured_images" | ||||||
|       android:parentActivityName=".contributions.MainActivity" /> |       android:parentActivityName=".contributions.MainActivity" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".explore.depictions.WikidataItemDetailsActivity" |       android:name=".explore.depictions.WikidataItemDetailsActivity" | ||||||
|             android:label="@string/title_activity_featured_images" |  | ||||||
|       android:configChanges="screenSize|keyboard|orientation" |       android:configChanges="screenSize|keyboard|orientation" | ||||||
|  |       android:label="@string/title_activity_featured_images" | ||||||
|       android:parentActivityName=".contributions.MainActivity" /> |       android:parentActivityName=".contributions.MainActivity" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".explore.SearchActivity" |       android:name=".explore.SearchActivity" | ||||||
|  |       android:configChanges="orientation|keyboardHidden|screenSize" | ||||||
|       android:label="@string/title_activity_search" |       android:label="@string/title_activity_search" | ||||||
|       android:launchMode="singleTop" |       android:launchMode="singleTop" | ||||||
|             android:configChanges="orientation|keyboardHidden|screenSize" |       android:parentActivityName=".contributions.MainActivity" /> | ||||||
|             android:parentActivityName=".contributions.MainActivity" |  | ||||||
|             /> |  | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".profile.ProfileActivity" |       android:name=".profile.ProfileActivity" | ||||||
|       android:configChanges="orientation|screenSize|keyboard" |       android:configChanges="orientation|screenSize|keyboard" | ||||||
|       android:label="@string/Profile" /> |       android:label="@string/Profile" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|       android:name=".review.ReviewActivity" |       android:name=".review.ReviewActivity" | ||||||
|       android:label="@string/title_activity_review" /> |       android:label="@string/title_activity_review" /> | ||||||
| 
 |  | ||||||
|     <activity |     <activity | ||||||
|           android:name=".LocationPicker.LocationPickerActivity" |       android:name=".locationpicker.LocationPickerActivity" | ||||||
|       android:label="Location Picker" /> |       android:label="Location Picker" /> | ||||||
| 
 | 
 | ||||||
|     <service |     <service | ||||||
|  | @ -182,16 +185,20 @@ | ||||||
|       <intent-filter> |       <intent-filter> | ||||||
|         <action android:name="android.accounts.AccountAuthenticator" /> |         <action android:name="android.accounts.AccountAuthenticator" /> | ||||||
|       </intent-filter> |       </intent-filter> | ||||||
|  | 
 | ||||||
|       <meta-data |       <meta-data | ||||||
|         android:name="android.accounts.AccountAuthenticator" |         android:name="android.accounts.AccountAuthenticator" | ||||||
|         android:resource="@xml/authenticator" /> |         android:resource="@xml/authenticator" /> | ||||||
|     </service> |     </service> | ||||||
| 
 |  | ||||||
|     <service |     <service | ||||||
|       android:name="org.acra.sender.SenderService" |       android:name="org.acra.sender.SenderService" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:process=":acra" /> |       android:process=":acra" /> | ||||||
|      |      | ||||||
|  |     <service | ||||||
|  |       android:name="androidx.work.impl.foreground.SystemForegroundService" | ||||||
|  |       android:foregroundServiceType="dataSync" /> | ||||||
|  | 
 | ||||||
|     <provider |     <provider | ||||||
|       android:name=".filepicker.ExtendedFileProvider" |       android:name=".filepicker.ExtendedFileProvider" | ||||||
|       android:authorities="${applicationId}.provider" |       android:authorities="${applicationId}.provider" | ||||||
|  | @ -201,42 +208,36 @@ | ||||||
|         android:name="android.support.FILE_PROVIDER_PATHS" |         android:name="android.support.FILE_PROVIDER_PATHS" | ||||||
|         android:resource="@xml/provider_paths" /> |         android:resource="@xml/provider_paths" /> | ||||||
|     </provider> |     </provider> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".category.CategoryContentProvider" |       android:name=".category.CategoryContentProvider" | ||||||
|       android:authorities="${applicationId}.categories.contentprovider" |       android:authorities="${applicationId}.categories.contentprovider" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:label="@string/provider_categories" |       android:label="@string/provider_categories" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".explore.recentsearches.RecentSearchesContentProvider" |       android:name=".explore.recentsearches.RecentSearchesContentProvider" | ||||||
|       android:authorities="${applicationId}.explore.recentsearches.contentprovider" |       android:authorities="${applicationId}.explore.recentsearches.contentprovider" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:label="@string/provider_searches" |       android:label="@string/provider_searches" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".recentlanguages.RecentLanguagesContentProvider" |       android:name=".recentlanguages.RecentLanguagesContentProvider" | ||||||
|       android:authorities="${applicationId}.recentlanguages.contentprovider" |       android:authorities="${applicationId}.recentlanguages.contentprovider" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:label="@string/provider_recent_languages" |       android:label="@string/provider_recent_languages" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".bookmarks.pictures.BookmarkPicturesContentProvider" |       android:name=".bookmarks.pictures.BookmarkPicturesContentProvider" | ||||||
|       android:authorities="${applicationId}.bookmarks.contentprovider" |       android:authorities="${applicationId}.bookmarks.contentprovider" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:label="@string/provider_bookmarks" |       android:label="@string/provider_bookmarks" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".bookmarks.locations.BookmarkLocationsContentProvider" |       android:name=".bookmarks.locations.BookmarkLocationsContentProvider" | ||||||
|       android:authorities="${applicationId}.bookmarks.locations.contentprovider" |       android:authorities="${applicationId}.bookmarks.locations.contentprovider" | ||||||
|       android:exported="false" |       android:exported="false" | ||||||
|       android:label="@string/provider_bookmarks_location" |       android:label="@string/provider_bookmarks_location" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 |  | ||||||
|     <provider |     <provider | ||||||
|       android:name=".bookmarks.items.BookmarkItemsContentProvider" |       android:name=".bookmarks.items.BookmarkItemsContentProvider" | ||||||
|       android:authorities="${applicationId}.bookmarks.items.contentprovider" |       android:authorities="${applicationId}.bookmarks.items.contentprovider" | ||||||
|  | @ -244,7 +245,8 @@ | ||||||
|       android:label="@string/provider_bookmarks_location" |       android:label="@string/provider_bookmarks_location" | ||||||
|       android:syncable="false" /> |       android:syncable="false" /> | ||||||
| 
 | 
 | ||||||
|       <receiver android:name=".widget.PicOfDayAppWidget" |     <receiver | ||||||
|  |       android:name=".widget.PicOfDayAppWidget" | ||||||
|       android:exported="true"> |       android:exported="true"> | ||||||
|       <intent-filter> |       <intent-filter> | ||||||
|         <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> |         <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> | ||||||
|  | @ -255,8 +257,9 @@ | ||||||
|         android:resource="@xml/pic_of_day_app_widget_info" /> |         android:resource="@xml/pic_of_day_app_widget_info" /> | ||||||
|     </receiver> |     </receiver> | ||||||
| 
 | 
 | ||||||
|         <uses-library android:name="org.apache.http.legacy" android:required="false" /> |     <uses-library | ||||||
| 
 |       android:name="org.apache.http.legacy" | ||||||
|  |       android:required="false" /> | ||||||
|   </application> |   </application> | ||||||
| 
 | 
 | ||||||
| </manifest> | </manifest> | ||||||
|  |  | ||||||
|  | @ -180,8 +180,8 @@ public class AboutActivity extends BaseActivity { | ||||||
|             getString(R.string.about_translate_cancel), |             getString(R.string.about_translate_cancel), | ||||||
|             positiveButtonRunnable, |             positiveButtonRunnable, | ||||||
|             () -> {}, |             () -> {}, | ||||||
|             spinner, |             spinner | ||||||
|             true); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -39,14 +39,20 @@ class BaseMarker { | ||||||
|     constructor() { |     constructor() { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fun fromResource(context: Context, drawableResId: Int) { |     fun fromResource( | ||||||
|  |         context: Context, | ||||||
|  |         drawableResId: Int, | ||||||
|  |     ) { | ||||||
|         val drawable: Drawable = context.resources.getDrawable(drawableResId) |         val drawable: Drawable = context.resources.getDrawable(drawableResId) | ||||||
|         icon = if (drawable is BitmapDrawable) { |         icon = | ||||||
|             (drawable as BitmapDrawable).bitmap |             if (drawable is BitmapDrawable) { | ||||||
|  |                 drawable.bitmap | ||||||
|             } else { |             } else { | ||||||
|             val bitmap = Bitmap.createBitmap( |                 val bitmap = | ||||||
|  |                     Bitmap.createBitmap( | ||||||
|                         drawable.intrinsicWidth, |                         drawable.intrinsicWidth, | ||||||
|                 drawable.intrinsicHeight, Bitmap.Config.ARGB_8888 |                         drawable.intrinsicHeight, | ||||||
|  |                         Bitmap.Config.ARGB_8888, | ||||||
|                     ) |                     ) | ||||||
|                 val canvas = Canvas(bitmap) |                 val canvas = Canvas(bitmap) | ||||||
|                 drawable.setBounds(0, 0, canvas.width, canvas.height) |                 drawable.setBounds(0, 0, canvas.width, canvas.height) | ||||||
|  | @ -55,10 +61,3 @@ class BaseMarker { | ||||||
|             } |             } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ object BetaConstants { | ||||||
|      * production server where beta server does not work |      * production server where beta server does not work | ||||||
|      */ |      */ | ||||||
|     const val COMMONS_URL = "https://commons.wikimedia.org/" |     const val COMMONS_URL = "https://commons.wikimedia.org/" | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Commons production's depicts property which is used in beta for some specific GET calls on |      * Commons production's depicts property which is used in beta for some specific GET calls on | ||||||
|      * production server where beta server does not work |      * production server where beta server does not work | ||||||
|  |  | ||||||
|  | @ -3,31 +3,31 @@ package fr.free.nrw.commons | ||||||
| import android.os.Parcel | import android.os.Parcel | ||||||
| import android.os.Parcelable | import android.os.Parcelable | ||||||
| 
 | 
 | ||||||
| class CameraPosition(val latitude: Double, val longitude: Double, val zoom: Double) : Parcelable { | class CameraPosition( | ||||||
| 
 |     val latitude: Double, | ||||||
|  |     val longitude: Double, | ||||||
|  |     val zoom: Double, | ||||||
|  | ) : Parcelable { | ||||||
|     constructor(parcel: Parcel) : this( |     constructor(parcel: Parcel) : this( | ||||||
|         parcel.readDouble(), |         parcel.readDouble(), | ||||||
|         parcel.readDouble(), |         parcel.readDouble(), | ||||||
|         parcel.readDouble() |         parcel.readDouble(), | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     override fun writeToParcel(parcel: Parcel, flags: Int) { |     override fun writeToParcel( | ||||||
|  |         parcel: Parcel, | ||||||
|  |         flags: Int, | ||||||
|  |     ) { | ||||||
|         parcel.writeDouble(latitude) |         parcel.writeDouble(latitude) | ||||||
|         parcel.writeDouble(longitude) |         parcel.writeDouble(longitude) | ||||||
|         parcel.writeDouble(zoom) |         parcel.writeDouble(zoom) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     override fun describeContents(): Int { |     override fun describeContents(): Int = 0 | ||||||
|         return 0 |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     companion object CREATOR : Parcelable.Creator<CameraPosition> { |     companion object CREATOR : Parcelable.Creator<CameraPosition> { | ||||||
|         override fun createFromParcel(parcel: Parcel): CameraPosition { |         override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel) | ||||||
|             return CameraPosition(parcel) |  | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         override fun newArray(size: Int): Array<CameraPosition?> { |         override fun newArray(size: Int): Array<CameraPosition?> = arrayOfNulls(size) | ||||||
|             return arrayOfNulls(size) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,440 +0,0 @@ | ||||||
| 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.Activity; |  | ||||||
| import android.app.NotificationChannel; |  | ||||||
| import android.app.NotificationManager; |  | ||||||
| import android.content.Context; |  | ||||||
| import android.content.Intent; |  | ||||||
| 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.MultiDexApplication; |  | ||||||
| import com.facebook.drawee.backends.pipeline.Fresco; |  | ||||||
| import com.facebook.imagepipeline.core.ImagePipeline; |  | ||||||
| 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.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.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; |  | ||||||
| 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 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 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 loginMessageIntentKey = "loginMessage"; |  | ||||||
|     public static final String loginUsernameIntentKey = "loginUsername"; |  | ||||||
| 
 |  | ||||||
|     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 |  | ||||||
|     CommonsCookieJar cookieJar; |  | ||||||
| 
 |  | ||||||
|     @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 REPORT_EMAIL = "commons-app-android-private@googlegroups.com"; |  | ||||||
| 
 |  | ||||||
|     public static final String REPORT_EMAIL_SUBJECT = "Report a violation"; |  | ||||||
| 
 |  | ||||||
|     public static final String NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll"; |  | ||||||
| 
 |  | ||||||
|     public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --"; |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Constants End |  | ||||||
|      */ |  | ||||||
| 
 |  | ||||||
|     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<>(); |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * In-memory list of uploads that have been cancelled by the user |  | ||||||
|      */ |  | ||||||
|     public static HashSet<String> cancelledUploads = new HashSet<>(); |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Used to declare and initialize various components and dependencies |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onCreate() { |  | ||||||
|         super.onCreate(); |  | ||||||
| 
 |  | ||||||
|         INSTANCE = this; |  | ||||||
|         ACRA.init(this); |  | ||||||
| 
 |  | ||||||
|         ApplicationlessInjection |  | ||||||
|             .getInstance(this) |  | ||||||
|             .getCommonsApplicationComponent() |  | ||||||
|             .inject(this); |  | ||||||
| 
 |  | ||||||
|         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()); |  | ||||||
| 
 |  | ||||||
|         // 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; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * 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(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * 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. |  | ||||||
|      */ |  | ||||||
|     public static class BaseLogoutListener implements CommonsApplication.LogoutListener { |  | ||||||
| 
 |  | ||||||
|         Context ctx; |  | ||||||
|         String loginMessage, userName; |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Constructor for BaseLogoutListener. |  | ||||||
|          * |  | ||||||
|          * @param ctx Application context |  | ||||||
|          */ |  | ||||||
|         public BaseLogoutListener(final Context ctx) { |  | ||||||
|             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 |  | ||||||
|          */ |  | ||||||
|         public BaseLogoutListener(final Context ctx, final String loginMessage, |  | ||||||
|             final String loginUsername) { |  | ||||||
|             this.ctx = ctx; |  | ||||||
|             this.loginMessage = loginMessage; |  | ||||||
|             this.userName = loginUsername; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override |  | ||||||
|         public void onLogoutComplete() { |  | ||||||
|             Timber.d("Logout complete callback received."); |  | ||||||
|             final Intent loginIntent = new Intent(ctx, LoginActivity.class); |  | ||||||
|             loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) |  | ||||||
|                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |  | ||||||
| 
 |  | ||||||
|             if (loginMessage != null) { |  | ||||||
|                 loginIntent.putExtra(loginMessageIntentKey, loginMessage); |  | ||||||
|             } |  | ||||||
|             if (userName != null) { |  | ||||||
|                 loginIntent.putExtra(loginUsernameIntentKey, 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. |  | ||||||
|      */ |  | ||||||
|     public static class ActivityLogoutListener extends BaseLogoutListener { |  | ||||||
| 
 |  | ||||||
|         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. |  | ||||||
|          */ |  | ||||||
|         public ActivityLogoutListener(final Activity activity, final Context ctx) { |  | ||||||
|             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. |  | ||||||
|          */ |  | ||||||
|         public ActivityLogoutListener(final Activity activity, final Context ctx, |  | ||||||
|             final String loginMessage, final String loginUsername) { |  | ||||||
|             super(activity, loginMessage, loginUsername); |  | ||||||
|             this.activity = activity; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override |  | ||||||
|         public void onLogoutComplete() { |  | ||||||
|             super.onLogoutComplete(); |  | ||||||
|             activity.finish(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
							
								
								
									
										414
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										414
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,414 @@ | ||||||
|  | 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.BookmarkItemsDao | ||||||
|  | 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.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 | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             contributionDao.deleteAll() | ||||||
|  |         } catch (e: SQLiteException) { | ||||||
|  |             Timber.e(e) | ||||||
|  |         } | ||||||
|  |         BookmarkPicturesDao.Table.onDelete(db) | ||||||
|  |         BookmarkLocationsDao.Table.onDelete(db) | ||||||
|  |         BookmarkItemsDao.Table.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) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @ -1,77 +0,0 @@ | ||||||
| package fr.free.nrw.commons.LocationPicker; |  | ||||||
| 
 |  | ||||||
| import android.app.Activity; |  | ||||||
| import android.content.Intent; |  | ||||||
| import fr.free.nrw.commons.CameraPosition; |  | ||||||
| import fr.free.nrw.commons.Media; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Helper class for starting the activity |  | ||||||
|  */ |  | ||||||
| public final class LocationPicker { |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Getting camera position from the intent using constants |  | ||||||
|      * |  | ||||||
|      * @param data intent |  | ||||||
|      * @return CameraPosition |  | ||||||
|      */ |  | ||||||
|     public static CameraPosition getCameraPosition(final Intent data) { |  | ||||||
|         return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public static class IntentBuilder { |  | ||||||
| 
 |  | ||||||
|         private final Intent intent; |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Creates a new builder that creates an intent to launch the place picker activity. |  | ||||||
|          */ |  | ||||||
|         public IntentBuilder() { |  | ||||||
|             intent = new Intent(); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Gets and puts location in intent |  | ||||||
|          * @param position CameraPosition |  | ||||||
|          * @return LocationPicker.IntentBuilder |  | ||||||
|          */ |  | ||||||
|         public LocationPicker.IntentBuilder defaultLocation( |  | ||||||
|             final CameraPosition position) { |  | ||||||
|           intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position); |  | ||||||
|           return this; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Gets and puts activity name in intent |  | ||||||
|          * @param activity activity key |  | ||||||
|          * @return LocationPicker.IntentBuilder |  | ||||||
|          */ |  | ||||||
|         public LocationPicker.IntentBuilder activityKey( |  | ||||||
|             final String activity) { |  | ||||||
|           intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity); |  | ||||||
|           return this; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Gets and puts media in intent |  | ||||||
|          * @param media Media |  | ||||||
|          * @return LocationPicker.IntentBuilder |  | ||||||
|          */ |  | ||||||
|         public LocationPicker.IntentBuilder media( |  | ||||||
|                 final Media media) { |  | ||||||
|               intent.putExtra(LocationPickerConstants.MEDIA, media); |  | ||||||
|               return this; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|         /** |  | ||||||
|          * Gets and sets the activity |  | ||||||
|          * @param activity Activity |  | ||||||
|          * @return Intent |  | ||||||
|          */ |  | ||||||
|        public Intent build(final Activity activity) { |  | ||||||
|           intent.setClass(activity, LocationPickerActivity.class); |  | ||||||
|           return intent; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,623 +0,0 @@ | ||||||
| package fr.free.nrw.commons.LocationPicker; |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; |  | ||||||
| import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM; |  | ||||||
| import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL; |  | ||||||
| 
 |  | ||||||
| import android.Manifest.permission; |  | ||||||
| import android.annotation.SuppressLint; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.content.pm.PackageManager; |  | ||||||
| import android.graphics.Color; |  | ||||||
| import android.graphics.Paint; |  | ||||||
| import android.graphics.drawable.Drawable; |  | ||||||
| import android.location.LocationManager; |  | ||||||
| import android.os.Bundle; |  | ||||||
| import android.preference.PreferenceManager; |  | ||||||
| import android.text.Html; |  | ||||||
| import android.text.method.LinkMovementMethod; |  | ||||||
| import android.view.MotionEvent; |  | ||||||
| import android.view.View; |  | ||||||
| import android.view.Window; |  | ||||||
| import android.view.animation.OvershootInterpolator; |  | ||||||
| import android.widget.Button; |  | ||||||
| import android.widget.ImageView; |  | ||||||
| import android.widget.TextView; |  | ||||||
| import android.widget.Toast; |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| import androidx.appcompat.app.ActionBar; |  | ||||||
| import androidx.appcompat.app.AppCompatActivity; |  | ||||||
| import androidx.appcompat.widget.AppCompatTextView; |  | ||||||
| import androidx.constraintlayout.widget.ConstraintLayout; |  | ||||||
| import androidx.core.app.ActivityCompat; |  | ||||||
| import androidx.core.content.ContextCompat; |  | ||||||
| import com.google.android.material.floatingactionbutton.FloatingActionButton; |  | ||||||
| import fr.free.nrw.commons.CameraPosition; |  | ||||||
| import fr.free.nrw.commons.CommonsApplication; |  | ||||||
| import fr.free.nrw.commons.Media; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.Utils; |  | ||||||
| import fr.free.nrw.commons.auth.SessionManager; |  | ||||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient; |  | ||||||
| import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; |  | ||||||
| import fr.free.nrw.commons.coordinates.CoordinateEditHelper; |  | ||||||
| import fr.free.nrw.commons.filepicker.Constants; |  | ||||||
| import fr.free.nrw.commons.kvstore.BasicKvStore; |  | ||||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; |  | ||||||
| import fr.free.nrw.commons.location.LocationPermissionsHelper; |  | ||||||
| import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; |  | ||||||
| import fr.free.nrw.commons.location.LocationServiceManager; |  | ||||||
| import fr.free.nrw.commons.theme.BaseActivity; |  | ||||||
| import fr.free.nrw.commons.utils.DialogUtil; |  | ||||||
| import fr.free.nrw.commons.utils.SystemThemeUtils; |  | ||||||
| import io.reactivex.android.schedulers.AndroidSchedulers; |  | ||||||
| import io.reactivex.schedulers.Schedulers; |  | ||||||
| import java.util.List; |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import org.osmdroid.tileprovider.tilesource.TileSourceFactory; |  | ||||||
| import org.osmdroid.util.GeoPoint; |  | ||||||
| import org.osmdroid.util.constants.GeoConstants; |  | ||||||
| import org.osmdroid.views.CustomZoomButtonsController; |  | ||||||
| import org.osmdroid.views.overlay.Marker; |  | ||||||
| import org.osmdroid.views.overlay.Overlay; |  | ||||||
| import org.osmdroid.views.overlay.ScaleDiskOverlay; |  | ||||||
| import org.osmdroid.views.overlay.TilesOverlay; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Helps to pick location and return the result with an intent |  | ||||||
|  */ |  | ||||||
| public class LocationPickerActivity extends BaseActivity implements |  | ||||||
|     LocationPermissionCallback { |  | ||||||
|     /** |  | ||||||
|      * coordinateEditHelper: helps to edit coordinates |  | ||||||
|      */ |  | ||||||
|     @Inject |  | ||||||
|     CoordinateEditHelper coordinateEditHelper; |  | ||||||
|     /** |  | ||||||
|      * media : Media object |  | ||||||
|      */ |  | ||||||
|     private Media media; |  | ||||||
|     /** |  | ||||||
|      * cameraPosition : position of picker |  | ||||||
|      */ |  | ||||||
|     private CameraPosition cameraPosition; |  | ||||||
|     /** |  | ||||||
|      * markerImage : picker image |  | ||||||
|      */ |  | ||||||
|     private ImageView markerImage; |  | ||||||
|     /** |  | ||||||
|      * mapView : OSM Map |  | ||||||
|      */ |  | ||||||
|     private org.osmdroid.views.MapView mapView; |  | ||||||
|     /** |  | ||||||
|      * tvAttribution : credit |  | ||||||
|      */ |  | ||||||
|     private AppCompatTextView tvAttribution; |  | ||||||
|     /** |  | ||||||
|      * activity : activity key |  | ||||||
|      */ |  | ||||||
|     private String activity; |  | ||||||
|     /** |  | ||||||
|      * modifyLocationButton : button for start editing location |  | ||||||
|      */ |  | ||||||
|     Button modifyLocationButton; |  | ||||||
|     /** |  | ||||||
|      * removeLocationButton : button to remove location metadata |  | ||||||
|      */ |  | ||||||
|     Button removeLocationButton; |  | ||||||
|     /** |  | ||||||
|      * showInMapButton : button for showing in map |  | ||||||
|      */ |  | ||||||
|     TextView showInMapButton; |  | ||||||
|     /** |  | ||||||
|      * placeSelectedButton : fab for selecting location |  | ||||||
|      */ |  | ||||||
|     FloatingActionButton placeSelectedButton; |  | ||||||
|     /** |  | ||||||
|      * fabCenterOnLocation: button for center on location; |  | ||||||
|      */ |  | ||||||
|     FloatingActionButton fabCenterOnLocation; |  | ||||||
|     /** |  | ||||||
|      * shadow : imageview of shadow |  | ||||||
|      */ |  | ||||||
|     private ImageView shadow; |  | ||||||
|     /** |  | ||||||
|      * largeToolbarText : textView of shadow |  | ||||||
|      */ |  | ||||||
|     private TextView largeToolbarText; |  | ||||||
|     /** |  | ||||||
|      * smallToolbarText : textView of shadow |  | ||||||
|      */ |  | ||||||
|     private TextView smallToolbarText; |  | ||||||
|     /** |  | ||||||
|      * applicationKvStore : for storing values |  | ||||||
|      */ |  | ||||||
|     @Inject |  | ||||||
|     @Named("default_preferences") |  | ||||||
|     public |  | ||||||
|     JsonKvStore applicationKvStore; |  | ||||||
|     BasicKvStore store; |  | ||||||
|     /** |  | ||||||
|      * isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly |  | ||||||
|      */ |  | ||||||
|     @Inject |  | ||||||
|     SystemThemeUtils systemThemeUtils; |  | ||||||
|     private boolean isDarkTheme; |  | ||||||
|     private boolean moveToCurrentLocation; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     LocationServiceManager locationManager; |  | ||||||
|     LocationPermissionsHelper locationPermissionsHelper; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     SessionManager sessionManager; |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Constants |  | ||||||
|      */ |  | ||||||
|     private static final String CAMERA_POS = "cameraPosition"; |  | ||||||
|     private static final String ACTIVITY = "activity"; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     @SuppressLint("ClickableViewAccessibility") |  | ||||||
|     @Override |  | ||||||
|     protected void onCreate(@Nullable final Bundle savedInstanceState) { |  | ||||||
|         getWindow().requestFeature(Window.FEATURE_ACTION_BAR); |  | ||||||
|         super.onCreate(savedInstanceState); |  | ||||||
| 
 |  | ||||||
|         isDarkTheme = systemThemeUtils.isDeviceInNightMode(); |  | ||||||
|         moveToCurrentLocation = false; |  | ||||||
|         store = new BasicKvStore(this, "LocationPermissions"); |  | ||||||
| 
 |  | ||||||
|         getWindow().requestFeature(Window.FEATURE_ACTION_BAR); |  | ||||||
|         final ActionBar actionBar = getSupportActionBar(); |  | ||||||
|         if (actionBar != null) { |  | ||||||
|             actionBar.hide(); |  | ||||||
|         } |  | ||||||
|         setContentView(R.layout.activity_location_picker); |  | ||||||
| 
 |  | ||||||
|         if (savedInstanceState == null) { |  | ||||||
|             cameraPosition = getIntent() |  | ||||||
|                 .getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); |  | ||||||
|             activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY); |  | ||||||
|             media = getIntent().getParcelableExtra(LocationPickerConstants.MEDIA); |  | ||||||
|         }else{ |  | ||||||
|             cameraPosition = savedInstanceState.getParcelable(CAMERA_POS); |  | ||||||
|             activity = savedInstanceState.getString(ACTIVITY); |  | ||||||
|             media = savedInstanceState.getParcelable("sMedia"); |  | ||||||
|         } |  | ||||||
|         bindViews(); |  | ||||||
|         addBackButtonListener(); |  | ||||||
|         addPlaceSelectedButton(); |  | ||||||
|         addCredits(); |  | ||||||
|         getToolbarUI(); |  | ||||||
|         addCenterOnGPSButton(); |  | ||||||
| 
 |  | ||||||
|         org.osmdroid.config.Configuration.getInstance().load(getApplicationContext(), |  | ||||||
|             PreferenceManager.getDefaultSharedPreferences(getApplicationContext())); |  | ||||||
| 
 |  | ||||||
|         mapView.setTileSource(TileSourceFactory.WIKIMEDIA); |  | ||||||
|         mapView.setTilesScaledToDpi(true); |  | ||||||
|         mapView.setMultiTouchControls(true); |  | ||||||
| 
 |  | ||||||
|         org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put( |  | ||||||
|             "Referer", "http://maps.wikimedia.org/" |  | ||||||
|         ); |  | ||||||
|         mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); |  | ||||||
|         mapView.getController().setZoom(ZOOM_LEVEL); |  | ||||||
|         mapView.setOnTouchListener((v, event) -> { |  | ||||||
|             if (event.getAction() == MotionEvent.ACTION_MOVE) { |  | ||||||
|                 if (markerImage.getTranslationY() == 0) { |  | ||||||
|                     markerImage.animate().translationY(-75) |  | ||||||
|                         .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); |  | ||||||
|                 } |  | ||||||
|             } else if (event.getAction() == MotionEvent.ACTION_UP) { |  | ||||||
|                 markerImage.animate().translationY(0) |  | ||||||
|                     .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); |  | ||||||
|             } |  | ||||||
|             return false; |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         if ("UploadActivity".equals(activity)) { |  | ||||||
|             placeSelectedButton.setVisibility(View.GONE); |  | ||||||
|             modifyLocationButton.setVisibility(View.VISIBLE); |  | ||||||
|             removeLocationButton.setVisibility(View.VISIBLE); |  | ||||||
|             showInMapButton.setVisibility(View.VISIBLE); |  | ||||||
|             largeToolbarText.setText(getResources().getString(R.string.image_location)); |  | ||||||
|             smallToolbarText.setText(getResources(). |  | ||||||
|                 getString(R.string.check_whether_location_is_correct)); |  | ||||||
|             fabCenterOnLocation.setVisibility(View.GONE); |  | ||||||
|             markerImage.setVisibility(View.GONE); |  | ||||||
|             shadow.setVisibility(View.GONE); |  | ||||||
|             assert cameraPosition != null; |  | ||||||
|             showSelectedLocationMarker(new GeoPoint(cameraPosition.getLatitude(), |  | ||||||
|                 cameraPosition.getLongitude())); |  | ||||||
|         } |  | ||||||
|         setupMapView(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * For showing credits |  | ||||||
|      */ |  | ||||||
|     private void addCredits() { |  | ||||||
|         tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); |  | ||||||
|         tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * For setting up Dark Theme |  | ||||||
|      */ |  | ||||||
|     private void darkThemeSetup() { |  | ||||||
|         if (isDarkTheme) { |  | ||||||
|             shadow.setColorFilter(Color.argb(255, 255, 255, 255)); |  | ||||||
|             mapView.getOverlayManager().getTilesOverlay() |  | ||||||
|                 .setColorFilter(TilesOverlay.INVERT_COLORS); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Clicking back button destroy locationPickerActivity |  | ||||||
|      */ |  | ||||||
|     private void addBackButtonListener() { |  | ||||||
|         final ImageView backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button); |  | ||||||
|         backButton.setOnClickListener(v -> { |  | ||||||
|             finish(); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Binds mapView and location picker icon |  | ||||||
|      */ |  | ||||||
|     private void bindViews() { |  | ||||||
|         mapView = findViewById(R.id.map_view); |  | ||||||
|         markerImage = findViewById(R.id.location_picker_image_view_marker); |  | ||||||
|         tvAttribution = findViewById(R.id.tv_attribution); |  | ||||||
|         modifyLocationButton = findViewById(R.id.modify_location); |  | ||||||
|         removeLocationButton = findViewById(R.id.remove_location); |  | ||||||
|         showInMapButton = findViewById(R.id.show_in_map); |  | ||||||
|         showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase()); |  | ||||||
|         shadow = findViewById(R.id.location_picker_image_view_shadow); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets toolbar color |  | ||||||
|      */ |  | ||||||
|     private void getToolbarUI() { |  | ||||||
|         final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar); |  | ||||||
|         largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view); |  | ||||||
|         smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view); |  | ||||||
|         toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void setupMapView() { |  | ||||||
|         adjustCameraBasedOnOptions(); |  | ||||||
|         modifyLocationButton.setOnClickListener(v -> onClickModifyLocation()); |  | ||||||
|         removeLocationButton.setOnClickListener(v -> onClickRemoveLocation()); |  | ||||||
|         showInMapButton.setOnClickListener(v -> showInMap()); |  | ||||||
|         darkThemeSetup(); |  | ||||||
|         requestLocationPermissions(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Handles onclick event of modifyLocationButton |  | ||||||
|      */ |  | ||||||
|     private void onClickModifyLocation() { |  | ||||||
|         placeSelectedButton.setVisibility(View.VISIBLE); |  | ||||||
|         modifyLocationButton.setVisibility(View.GONE); |  | ||||||
|         removeLocationButton.setVisibility(View.GONE); |  | ||||||
|         showInMapButton.setVisibility(View.GONE); |  | ||||||
|         markerImage.setVisibility(View.VISIBLE); |  | ||||||
|         shadow.setVisibility(View.VISIBLE); |  | ||||||
|         largeToolbarText.setText(getResources().getString(R.string.choose_a_location)); |  | ||||||
|         smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust)); |  | ||||||
|         fabCenterOnLocation.setVisibility(View.VISIBLE); |  | ||||||
|         removeSelectedLocationMarker(); |  | ||||||
|         if (cameraPosition != null && mapView != null) { |  | ||||||
|             if (mapView.getController() != null) { |  | ||||||
|                 mapView.getController().animateTo(new GeoPoint(cameraPosition.getLatitude(), |  | ||||||
|                     cameraPosition.getLongitude())); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Handles onclick event of removeLocationButton |  | ||||||
|      */ |  | ||||||
|     private void onClickRemoveLocation() { |  | ||||||
|         DialogUtil.showAlertDialog(this, |  | ||||||
|             getString(R.string.remove_location_warning_title), |  | ||||||
|             getString(R.string.remove_location_warning_desc), |  | ||||||
|             getString(R.string.continue_message), |  | ||||||
|             getString(R.string.cancel), () -> removeLocationFromImage(), null); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Method to remove the location from the picture |  | ||||||
|      */ |  | ||||||
|     private void removeLocationFromImage() { |  | ||||||
|         if (media != null) { |  | ||||||
|             compositeDisposable.add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext() |  | ||||||
|                     , media, "0.0", "0.0", "0.0f") |  | ||||||
|                 .subscribeOn(Schedulers.io()) |  | ||||||
|                 .observeOn(AndroidSchedulers.mainThread()) |  | ||||||
|                 .subscribe(s -> { |  | ||||||
|                     Timber.d("Coordinates are removed from the image"); |  | ||||||
|                 })); |  | ||||||
|         } |  | ||||||
|         final Intent returningIntent = new Intent(); |  | ||||||
|         setResult(AppCompatActivity.RESULT_OK, returningIntent); |  | ||||||
|         finish(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Show the location in map app |  | ||||||
|      */ |  | ||||||
|     public void showInMap() { |  | ||||||
|         Utils.handleGeoCoordinates(this, |  | ||||||
|             new fr.free.nrw.commons.location.LatLng(mapView.getMapCenter().getLatitude(), |  | ||||||
|                 mapView.getMapCenter().getLongitude(), 0.0f)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * move the location to the current media coordinates |  | ||||||
|      */ |  | ||||||
|     private void adjustCameraBasedOnOptions() { |  | ||||||
|         if (cameraPosition != null) { |  | ||||||
|             mapView.getController().setCenter(new GeoPoint(cameraPosition.getLatitude(), |  | ||||||
|                 cameraPosition.getLongitude())); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Select the preferable location |  | ||||||
|      */ |  | ||||||
|     private void addPlaceSelectedButton() { |  | ||||||
|         placeSelectedButton = findViewById(R.id.location_chosen_button); |  | ||||||
|         placeSelectedButton.setOnClickListener(view -> placeSelected()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Return the intent with required data |  | ||||||
|      */ |  | ||||||
|     void placeSelected() { |  | ||||||
|         if (activity.equals("NoLocationUploadActivity")) { |  | ||||||
|             applicationKvStore.putString(LAST_LOCATION, |  | ||||||
|                 mapView.getMapCenter().getLatitude() |  | ||||||
|                     + "," |  | ||||||
|                     + mapView.getMapCenter().getLongitude()); |  | ||||||
|             applicationKvStore.putString(LAST_ZOOM, mapView.getZoomLevel() + ""); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (media == null) { |  | ||||||
|             final Intent returningIntent = new Intent(); |  | ||||||
|             returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, |  | ||||||
|                 new CameraPosition(mapView.getMapCenter().getLatitude(), |  | ||||||
|                     mapView.getMapCenter().getLongitude(), 14.0)); |  | ||||||
|             setResult(AppCompatActivity.RESULT_OK, returningIntent); |  | ||||||
|         } else { |  | ||||||
|             updateCoordinates(String.valueOf(mapView.getMapCenter().getLatitude()), |  | ||||||
|                 String.valueOf(mapView.getMapCenter().getLongitude()), |  | ||||||
|                 String.valueOf(0.0f)); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         finish(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Fetched coordinates are replaced with existing coordinates by a POST API call. |  | ||||||
|      * @param Latitude to be added |  | ||||||
|      * @param Longitude to be added |  | ||||||
|      * @param Accuracy to be added |  | ||||||
|      */ |  | ||||||
|     public void updateCoordinates(final String Latitude, final String Longitude, |  | ||||||
|         final String Accuracy) { |  | ||||||
|         if (media == null) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         try { |  | ||||||
|             compositeDisposable.add( |  | ||||||
|                 coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media, |  | ||||||
|                         Latitude, Longitude, Accuracy) |  | ||||||
|                     .subscribeOn(Schedulers.io()) |  | ||||||
|                     .observeOn(AndroidSchedulers.mainThread()) |  | ||||||
|                     .subscribe(s -> { |  | ||||||
|                             Timber.d("Coordinates are added."); |  | ||||||
|                         })); |  | ||||||
|         } catch (Exception e) { |  | ||||||
|             if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) { |  | ||||||
|                 final String username = sessionManager.getUserName(); |  | ||||||
|                 final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( |  | ||||||
|                     this, |  | ||||||
|                     getString(R.string.invalid_login_message), |  | ||||||
|                     username |  | ||||||
|                 ); |  | ||||||
| 
 |  | ||||||
|                 CommonsApplication.getInstance().clearApplicationData( |  | ||||||
|                     this, logoutListener); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Center the camera on the last saved location |  | ||||||
|      */ |  | ||||||
|     private void addCenterOnGPSButton() { |  | ||||||
|         fabCenterOnLocation = findViewById(R.id.center_on_gps); |  | ||||||
|         fabCenterOnLocation.setOnClickListener(view -> { |  | ||||||
|             moveToCurrentLocation = true; |  | ||||||
|             requestLocationPermissions(); |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Adds selected location marker on the map |  | ||||||
|      */ |  | ||||||
|     private void showSelectedLocationMarker(GeoPoint point) { |  | ||||||
|         Drawable icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker); |  | ||||||
|         Marker marker = new Marker(mapView); |  | ||||||
|         marker.setPosition(point); |  | ||||||
|         marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM); |  | ||||||
|         marker.setIcon(icon); |  | ||||||
|         marker.setInfoWindow(null); |  | ||||||
|         mapView.getOverlays().add(marker); |  | ||||||
|         mapView.invalidate(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Removes selected location marker from the map |  | ||||||
|      */ |  | ||||||
|     private void removeSelectedLocationMarker() { |  | ||||||
|         List<Overlay> overlays = mapView.getOverlays(); |  | ||||||
|         for (int i = 0; i < overlays.size(); i++) { |  | ||||||
|             if (overlays.get(i) instanceof Marker) { |  | ||||||
|                 Marker item = (Marker) overlays.get(i); |  | ||||||
|                 if (cameraPosition.getLatitude() == item.getPosition().getLatitude() |  | ||||||
|                     && cameraPosition.getLongitude() == item.getPosition().getLongitude()) { |  | ||||||
|                     mapView.getOverlays().remove(i); |  | ||||||
|                     mapView.invalidate(); |  | ||||||
|                     break; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Center the map at user's current location |  | ||||||
|      */ |  | ||||||
|     private void requestLocationPermissions() { |  | ||||||
|         locationPermissionsHelper = new LocationPermissionsHelper( |  | ||||||
|             this, locationManager, this); |  | ||||||
|         locationPermissionsHelper.requestForLocationAccess(R.string.location_permission_title, |  | ||||||
|             R.string.upload_map_location_access); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onRequestPermissionsResult(final int requestCode, |  | ||||||
|         @NonNull final String[] permissions, |  | ||||||
|         @NonNull final int[] grantResults) { |  | ||||||
|         if (requestCode == Constants.RequestCodes.LOCATION |  | ||||||
|             && grantResults[0] == PackageManager.PERMISSION_GRANTED) { |  | ||||||
|             onLocationPermissionGranted(); |  | ||||||
|         } else { |  | ||||||
|             onLocationPermissionDenied(getString(R.string.upload_map_location_access)); |  | ||||||
|         } |  | ||||||
|         super.onRequestPermissionsResult(requestCode, permissions, grantResults); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onResume() { |  | ||||||
|         super.onResume(); |  | ||||||
|         mapView.onResume(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onPause() { |  | ||||||
|         super.onPause(); |  | ||||||
|         mapView.onPause(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onLocationPermissionDenied(String toastMessage) { |  | ||||||
|         if (!ActivityCompat.shouldShowRequestPermissionRationale(this, |  | ||||||
|             permission.ACCESS_FINE_LOCATION)) { |  | ||||||
|             if (!locationPermissionsHelper.checkLocationPermission(this)) { |  | ||||||
|                 if (store.getBoolean("isPermissionDenied", false)) { |  | ||||||
|                     // means user has denied location permission twice or checked the "Don't show again" |  | ||||||
|                     locationPermissionsHelper.showAppSettingsDialog(this, |  | ||||||
|                         R.string.upload_map_location_access); |  | ||||||
|                 } else { |  | ||||||
|                     Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show(); |  | ||||||
|                 } |  | ||||||
|                 store.putBoolean("isPermissionDenied", true); |  | ||||||
|             } |  | ||||||
|         } else { |  | ||||||
|             Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onLocationPermissionGranted() { |  | ||||||
|         if (moveToCurrentLocation || !(activity.equals("MediaActivity"))) { |  | ||||||
|             if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { |  | ||||||
|                 locationManager.requestLocationUpdatesFromProvider( |  | ||||||
|                     LocationManager.NETWORK_PROVIDER); |  | ||||||
|                 locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); |  | ||||||
|                 getLocation(); |  | ||||||
|             } else { |  | ||||||
|                 getLocation(); |  | ||||||
|                 locationPermissionsHelper.showLocationOffDialog(this, |  | ||||||
|                     R.string.ask_to_turn_location_on_text); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets new location if locations services are on, else gets last location |  | ||||||
|      */ |  | ||||||
|     private void getLocation() { |  | ||||||
|         fr.free.nrw.commons.location.LatLng currLocation = locationManager.getLastLocation(); |  | ||||||
|         if (currLocation != null) { |  | ||||||
|             GeoPoint currLocationGeopoint = new GeoPoint(currLocation.getLatitude(), |  | ||||||
|                 currLocation.getLongitude()); |  | ||||||
|             addLocationMarker(currLocationGeopoint); |  | ||||||
|             mapView.getController().setCenter(currLocationGeopoint); |  | ||||||
|             mapView.getController().animateTo(currLocationGeopoint); |  | ||||||
|             markerImage.setTranslationY(0); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void addLocationMarker(GeoPoint geoPoint) { |  | ||||||
|         if (moveToCurrentLocation) { |  | ||||||
|             mapView.getOverlays().clear(); |  | ||||||
|         } |  | ||||||
|         ScaleDiskOverlay diskOverlay = |  | ||||||
|             new ScaleDiskOverlay(this, |  | ||||||
|                 geoPoint, 2000, GeoConstants.UnitOfMeasure.foot); |  | ||||||
|         Paint circlePaint = new Paint(); |  | ||||||
|         circlePaint.setColor(Color.rgb(128, 128, 128)); |  | ||||||
|         circlePaint.setStyle(Paint.Style.STROKE); |  | ||||||
|         circlePaint.setStrokeWidth(2f); |  | ||||||
|         diskOverlay.setCirclePaint2(circlePaint); |  | ||||||
|         Paint diskPaint = new Paint(); |  | ||||||
|         diskPaint.setColor(Color.argb(40, 128, 128, 128)); |  | ||||||
|         diskPaint.setStyle(Paint.Style.FILL_AND_STROKE); |  | ||||||
|         diskOverlay.setCirclePaint1(diskPaint); |  | ||||||
|         diskOverlay.setDisplaySizeMin(900); |  | ||||||
|         diskOverlay.setDisplaySizeMax(1700); |  | ||||||
|         mapView.getOverlays().add(diskOverlay); |  | ||||||
|         org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( |  | ||||||
|             mapView); |  | ||||||
|         startMarker.setPosition(geoPoint); |  | ||||||
|         startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, |  | ||||||
|             org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM); |  | ||||||
|         startMarker.setIcon( |  | ||||||
|             ContextCompat.getDrawable(this, R.drawable.current_location_marker)); |  | ||||||
|         startMarker.setTitle("Your Location"); |  | ||||||
|         startMarker.setTextLabelFontSize(24); |  | ||||||
|         mapView.getOverlays().add(startMarker); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Saves the state of the activity |  | ||||||
|      * @param outState Bundle |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onSaveInstanceState(@NonNull final Bundle outState) { |  | ||||||
|         super.onSaveInstanceState(outState); |  | ||||||
|         if(cameraPosition!=null){ |  | ||||||
|             outState.putParcelable(CAMERA_POS, cameraPosition); |  | ||||||
|         } |  | ||||||
|         if(activity!=null){ |  | ||||||
|             outState.putString(ACTIVITY, activity); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if(media!=null){ |  | ||||||
|             outState.putParcelable("sMedia", media); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,20 +0,0 @@ | ||||||
| package fr.free.nrw.commons.LocationPicker; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Constants need for location picking |  | ||||||
|  */ |  | ||||||
| public final class LocationPickerConstants { |  | ||||||
| 
 |  | ||||||
|     public static final String ACTIVITY_KEY |  | ||||||
|         = "location.picker.activity"; |  | ||||||
| 
 |  | ||||||
|     public static final String MAP_CAMERA_POSITION |  | ||||||
|         = "location.picker.cameraPosition"; |  | ||||||
| 
 |  | ||||||
|     public static final String MEDIA |  | ||||||
|         = "location.picker.media"; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     private LocationPickerConstants() { |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,63 +0,0 @@ | ||||||
| package fr.free.nrw.commons.LocationPicker; |  | ||||||
| 
 |  | ||||||
| import android.app.Application; |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.lifecycle.AndroidViewModel; |  | ||||||
| import androidx.lifecycle.MutableLiveData; |  | ||||||
| import fr.free.nrw.commons.CameraPosition; |  | ||||||
| import org.jetbrains.annotations.NotNull; |  | ||||||
| import retrofit2.Call; |  | ||||||
| import retrofit2.Callback; |  | ||||||
| import retrofit2.Response; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Observes live camera position data |  | ||||||
|  */ |  | ||||||
| public class LocationPickerViewModel extends AndroidViewModel implements Callback<CameraPosition> { |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Wrapping CameraPosition with MutableLiveData |  | ||||||
|      */ |  | ||||||
|     private final MutableLiveData<CameraPosition> result = new MutableLiveData<>(); |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Constructor for this class |  | ||||||
|      * |  | ||||||
|      * @param application Application |  | ||||||
|      */ |  | ||||||
|     public LocationPickerViewModel(@NonNull final Application application) { |  | ||||||
|         super(application); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Responses on camera position changing |  | ||||||
|      * |  | ||||||
|      * @param call     Call<CameraPosition> |  | ||||||
|      * @param response Response<CameraPosition> |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onResponse(final @NotNull Call<CameraPosition> call, |  | ||||||
|         final Response<CameraPosition> response) { |  | ||||||
|         if (response.body() == null) { |  | ||||||
|             result.setValue(null); |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|         result.setValue(response.body()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onFailure(final @NotNull Call<CameraPosition> call, final @NotNull Throwable t) { |  | ||||||
|         Timber.e(t); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets live CameraPosition |  | ||||||
|      * |  | ||||||
|      * @return MutableLiveData<CameraPosition> |  | ||||||
|      */ |  | ||||||
|     public MutableLiveData<CameraPosition> getResult() { |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
|  | @ -2,9 +2,12 @@ package fr.free.nrw.commons | ||||||
| 
 | 
 | ||||||
| import android.os.Parcelable | import android.os.Parcelable | ||||||
| import fr.free.nrw.commons.location.LatLng | import fr.free.nrw.commons.location.LatLng | ||||||
| import kotlinx.parcelize.Parcelize |  | ||||||
| import fr.free.nrw.commons.wikidata.model.page.PageTitle | import fr.free.nrw.commons.wikidata.model.page.PageTitle | ||||||
| import java.util.* | import kotlinx.parcelize.IgnoredOnParcel | ||||||
|  | import kotlinx.parcelize.Parcelize | ||||||
|  | import java.util.Date | ||||||
|  | import java.util.Locale | ||||||
|  | import java.util.UUID | ||||||
| 
 | 
 | ||||||
| @Parcelize | @Parcelize | ||||||
| class Media constructor( | class Media constructor( | ||||||
|  | @ -14,7 +17,6 @@ class Media constructor( | ||||||
|      */ |      */ | ||||||
|     var pageId: String = UUID.randomUUID().toString(), |     var pageId: String = UUID.randomUUID().toString(), | ||||||
|     var thumbUrl: String? = null, |     var thumbUrl: String? = null, | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Gets image URL |      * Gets image URL | ||||||
|      * @return Image URL |      * @return Image URL | ||||||
|  | @ -26,16 +28,11 @@ class Media constructor( | ||||||
|      */ |      */ | ||||||
|     var filename: String? = null, |     var filename: String? = null, | ||||||
|     /** |     /** | ||||||
|      * Gets the file description. |      * Gets or sets the file description. | ||||||
|      * @return file description as a string |      * @return file description as a string | ||||||
|      */ |  | ||||||
|     // monolingual description on input... |  | ||||||
|     /** |  | ||||||
|      * Sets the file description. |  | ||||||
|      * @param fallbackDescription the new description of the file |      * @param fallbackDescription the new description of the file | ||||||
|      */ |      */ | ||||||
|     var fallbackDescription: String? = null, |     var fallbackDescription: String? = null, | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Gets the upload date of the file. |      * Gets the upload date of the file. | ||||||
|      * Can be null. |      * Can be null. | ||||||
|  | @ -43,28 +40,19 @@ class Media constructor( | ||||||
|      */ |      */ | ||||||
|     var dateUploaded: Date? = null, |     var dateUploaded: Date? = null, | ||||||
|     /** |     /** | ||||||
|      * Gets the license name of the file. |      * Gets or sets the license name of the file. | ||||||
|      * @return license as a String |      * @return license as a String | ||||||
|      */ |  | ||||||
|     /** |  | ||||||
|      * Sets the license name of the file. |  | ||||||
|      * |  | ||||||
|      * @param license license name as a String |      * @param license license name as a String | ||||||
|      */ |      */ | ||||||
|     var license: String? = null, |     var license: String? = null, | ||||||
|     var licenseUrl: String? = null, |     var licenseUrl: String? = null, | ||||||
|     /** |     /** | ||||||
|      * Gets the name of the creator of the file. |      * Gets or sets the name of the creator of the file. | ||||||
|      * @return author name as a String |      * @return author name as a String | ||||||
|      */ |  | ||||||
|     /** |  | ||||||
|      * Sets the author name of the file. |  | ||||||
|      * @param author creator name as a string |      * @param author creator name as a string | ||||||
|      */ |      */ | ||||||
|     var author: String? = null, |     var author: String? = null, | ||||||
| 
 |  | ||||||
|     var user: String? = null, |     var user: String? = null, | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Gets the categories the file falls under. |      * Gets the categories the file falls under. | ||||||
|      * @return file categories as an ArrayList of Strings |      * @return file categories as an ArrayList of Strings | ||||||
|  | @ -83,15 +71,15 @@ class Media constructor( | ||||||
|      * Stores the mapping of category title to hidden attribute |      * Stores the mapping of category title to hidden attribute | ||||||
|      * Example: "Mountains" => false, "CC-BY-SA-2.0" => true |      * Example: "Mountains" => false, "CC-BY-SA-2.0" => true | ||||||
|      */ |      */ | ||||||
|     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap() |     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(), | ||||||
| ) : Parcelable { | ) : Parcelable { | ||||||
| 
 |  | ||||||
|     constructor( |     constructor( | ||||||
|         captions: Map<String, String>, |         captions: Map<String, String>, | ||||||
|         categories: List<String>?, |         categories: List<String>?, | ||||||
|         filename: String?, |         filename: String?, | ||||||
|         fallbackDescription: String?, |         fallbackDescription: String?, | ||||||
|         author: String?, user:String? |         author: String?, | ||||||
|  |         user: String?, | ||||||
|     ) : this( |     ) : this( | ||||||
|         filename = filename, |         filename = filename, | ||||||
|         fallbackDescription = fallbackDescription, |         fallbackDescription = fallbackDescription, | ||||||
|  | @ -99,7 +87,7 @@ class Media constructor( | ||||||
|         author = author, |         author = author, | ||||||
|         user = user, |         user = user, | ||||||
|         categories = categories, |         categories = categories, | ||||||
|         captions = captions |         captions = captions, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -108,10 +96,11 @@ class Media constructor( | ||||||
|      */ |      */ | ||||||
|     val displayTitle: String |     val displayTitle: String | ||||||
|         get() = |         get() = | ||||||
|             if (filename != null) |             if (filename != null) { | ||||||
|                 pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") |                 pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") | ||||||
|             else |             } else { | ||||||
|                 "" |                 "" | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Gets file page title |      * Gets file page title | ||||||
|  | @ -127,7 +116,8 @@ class Media constructor( | ||||||
|         get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) |         get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) | ||||||
| 
 | 
 | ||||||
|     val mostRelevantCaption: String |     val mostRelevantCaption: String | ||||||
|         get() = captions[Locale.getDefault().language] |         get() = | ||||||
|  |             captions[Locale.getDefault().language] | ||||||
|                 ?: captions.values.firstOrNull() |                 ?: captions.values.firstOrNull() | ||||||
|                 ?: displayTitle |                 ?: displayTitle | ||||||
| 
 | 
 | ||||||
|  | @ -135,9 +125,12 @@ class Media constructor( | ||||||
|      * Gets the categories the file falls under. |      * Gets the categories the file falls under. | ||||||
|      * @return file categories as an ArrayList of Strings |      * @return file categories as an ArrayList of Strings | ||||||
|      */ |      */ | ||||||
|  |     @IgnoredOnParcel | ||||||
|     var addedCategories: List<String>? = null |     var addedCategories: List<String>? = null | ||||||
|         // TODO added categories should be removed. It is added for a short fix. On category update, |         // TODO added categories should be removed. It is added for a short fix. On category update, | ||||||
|         //  categories should be re-fetched instead |         //  categories should be re-fetched instead | ||||||
|         get() = field // getter |         get() = field // getter | ||||||
|         set(value) { field = value }      // setter |         set(value) { | ||||||
|  |             field = value | ||||||
|  |         } // setter | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| package fr.free.nrw.commons | package fr.free.nrw.commons | ||||||
| 
 | 
 | ||||||
| import androidx.core.text.HtmlCompat | import androidx.core.text.HtmlCompat | ||||||
| import fr.free.nrw.commons.media.PAGE_ID_PREFIX |  | ||||||
| import fr.free.nrw.commons.media.IdAndCaptions | import fr.free.nrw.commons.media.IdAndCaptions | ||||||
| import fr.free.nrw.commons.media.MediaClient | import fr.free.nrw.commons.media.MediaClient | ||||||
|  | import fr.free.nrw.commons.media.PAGE_ID_PREFIX | ||||||
| import io.reactivex.Single | import io.reactivex.Single | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  | @ -17,42 +17,46 @@ import javax.inject.Singleton | ||||||
|  * to the media and may change due to editing. |  * to the media and may change due to editing. | ||||||
|  */ |  */ | ||||||
| @Singleton | @Singleton | ||||||
| class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) { | class MediaDataExtractor | ||||||
| 
 |     @Inject | ||||||
|  |     constructor( | ||||||
|  |         private val mediaClient: MediaClient, | ||||||
|  |     ) { | ||||||
|         fun fetchDepictionIdsAndLabels(media: Media) = |         fun fetchDepictionIdsAndLabels(media: Media) = | ||||||
|         mediaClient.getEntities(media.depictionIds) |             mediaClient | ||||||
|  |                 .getEntities(media.depictionIds) | ||||||
|                 .map { |                 .map { | ||||||
|                 it.entities() |                     it | ||||||
|  |                         .entities() | ||||||
|                         .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } |                         .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } | ||||||
|             } |                 }.map { it.map { (key, value) -> IdAndCaptions(key, value) } } | ||||||
|             .map { it.map { (key, value) -> IdAndCaptions(key, value) } } |  | ||||||
|                 .onErrorReturn { emptyList() } |                 .onErrorReturn { emptyList() } | ||||||
| 
 | 
 | ||||||
|     fun checkDeletionRequestExists(media: Media) = |         fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) | ||||||
|         mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) |  | ||||||
| 
 | 
 | ||||||
|         fun fetchDiscussion(media: Media) = |         fun fetchDiscussion(media: Media) = | ||||||
|         mediaClient.getPageHtml(media.filename!!.replace("File", "File talk")) |             mediaClient | ||||||
|  |                 .getPageHtml(media.filename!!.replace("File", "File talk")) | ||||||
|                 .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } |                 .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } | ||||||
|                 .onErrorReturn { |                 .onErrorReturn { | ||||||
|                     Timber.d("Error occurred while fetching discussion") |                     Timber.d("Error occurred while fetching discussion") | ||||||
|                     "" |                     "" | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|     fun refresh(media: Media): Single<Media> { |         fun refresh(media: Media): Single<Media> = | ||||||
|         return Single.ambArray( |             Single.ambArray( | ||||||
|             mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId) |                 mediaClient | ||||||
|  |                     .getMediaById(PAGE_ID_PREFIX + media.pageId) | ||||||
|  |                     .onErrorResumeNext { Single.never() }, | ||||||
|  |                 mediaClient | ||||||
|  |                     .getMediaSuppressingErrors(media.filename) | ||||||
|                     .onErrorResumeNext { Single.never() }, |                     .onErrorResumeNext { Single.never() }, | ||||||
|             mediaClient.getMediaSuppressingErrors(media.filename) |  | ||||||
|                 .onErrorResumeNext { Single.never() } |  | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|     } |         fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title) | ||||||
| 
 |  | ||||||
|     fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title); |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Fetches wikitext from mediaClient |          * Fetches wikitext from mediaClient | ||||||
|          */ |          */ | ||||||
|     fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title); |         fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -10,7 +10,9 @@ internal object Urls { | ||||||
|     const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md" |     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_PREFIX = "market://details?id=" | ||||||
|     const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/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_WEB_URL = "https://www.facebook.com/1921335171459985" | ||||||
|     const val FACEBOOK_APP_URL = "fb://page/1921335171459985" |     const val FACEBOOK_APP_URL = "fb://page/1921335171459985" | ||||||
|     const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana" |     const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana" | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ public class WelcomeActivity extends BaseActivity { | ||||||
|             copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater()); |             copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater()); | ||||||
|             final View contactPopupView = copyrightBinding.getRoot(); |             final View contactPopupView = copyrightBinding.getRoot(); | ||||||
|             dialogBuilder.setView(contactPopupView); |             dialogBuilder.setView(contactPopupView); | ||||||
|  |             dialogBuilder.setCancelable(false); | ||||||
|             dialog = dialogBuilder.create(); |             dialog = dialogBuilder.create(); | ||||||
|             dialog.show(); |             dialog.show(); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| package fr.free.nrw.commons.actions | package fr.free.nrw.commons.actions | ||||||
| 
 | 
 | ||||||
|  | import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||||
| import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | ||||||
| import io.reactivex.Observable | import io.reactivex.Observable | ||||||
| import io.reactivex.Single | import io.reactivex.Single | ||||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient |  | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * This class acts as a Client to facilitate wiki page editing |  * This class acts as a Client to facilitate wiki page editing | ||||||
|  | @ -14,9 +14,8 @@ import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||||
|  */ |  */ | ||||||
| class PageEditClient( | class PageEditClient( | ||||||
|     private val csrfTokenClient: CsrfTokenClient, |     private val csrfTokenClient: CsrfTokenClient, | ||||||
|     private val pageEditInterface: PageEditInterface |     private val pageEditInterface: PageEditInterface, | ||||||
| ) { | ) { | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Replace the content of a wiki page |      * Replace the content of a wiki page | ||||||
|      * @param pageTitle   Title of the page to edit |      * @param pageTitle   Title of the page to edit | ||||||
|  | @ -24,10 +23,17 @@ class PageEditClient( | ||||||
|      * @param summary     Edit summary |      * @param summary     Edit summary | ||||||
|      * @return whether the edit was successful |      * @return whether the edit was successful | ||||||
|      */ |      */ | ||||||
|     fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> { |     fun edit( | ||||||
|         return try { |         pageTitle: String, | ||||||
|             pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) |         text: String, | ||||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } |         summary: String, | ||||||
|  |     ): Observable<Boolean> = | ||||||
|  |         try { | ||||||
|  |             pageEditInterface | ||||||
|  |                 .postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) | ||||||
|  |                 .map { editResponse -> | ||||||
|  |                     editResponse.edit()!!.editSucceeded() | ||||||
|  |                 } | ||||||
|         } catch (throwable: Throwable) { |         } catch (throwable: Throwable) { | ||||||
|             if (throwable is InvalidLoginTokenException) { |             if (throwable is InvalidLoginTokenException) { | ||||||
|                 throw throwable |                 throw throwable | ||||||
|  | @ -35,6 +41,41 @@ class PageEditClient( | ||||||
|                 Observable.just(false) |                 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) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -44,9 +85,14 @@ class PageEditClient( | ||||||
|      * @param summary     Edit summary |      * @param summary     Edit summary | ||||||
|      * @return whether the edit was successful |      * @return whether the edit was successful | ||||||
|      */ |      */ | ||||||
|     fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> { |     fun appendEdit( | ||||||
|         return try { |         pageTitle: String, | ||||||
|             pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) |         appendText: String, | ||||||
|  |         summary: String, | ||||||
|  |     ): Observable<Boolean> = | ||||||
|  |         try { | ||||||
|  |             pageEditInterface | ||||||
|  |                 .postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) | ||||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } |                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||||
|         } catch (throwable: Throwable) { |         } catch (throwable: Throwable) { | ||||||
|             if (throwable is InvalidLoginTokenException) { |             if (throwable is InvalidLoginTokenException) { | ||||||
|  | @ -55,7 +101,6 @@ class PageEditClient( | ||||||
|                 Observable.just(false) |                 Observable.just(false) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Prepend text to the beginning of a wiki page |      * Prepend text to the beginning of a wiki page | ||||||
|  | @ -64,9 +109,14 @@ class PageEditClient( | ||||||
|      * @param summary     Edit summary |      * @param summary     Edit summary | ||||||
|      * @return whether the edit was successful |      * @return whether the edit was successful | ||||||
|      */ |      */ | ||||||
|     fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> { |     fun prependEdit( | ||||||
|         return try { |         pageTitle: String, | ||||||
|             pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) |         prependText: String, | ||||||
|  |         summary: String, | ||||||
|  |     ): Observable<Boolean> = | ||||||
|  |         try { | ||||||
|  |             pageEditInterface | ||||||
|  |                 .postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) | ||||||
|                 .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } |                 .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } | ||||||
|         } catch (throwable: Throwable) { |         } catch (throwable: Throwable) { | ||||||
|             if (throwable is InvalidLoginTokenException) { |             if (throwable is InvalidLoginTokenException) { | ||||||
|  | @ -75,8 +125,32 @@ class PageEditClient( | ||||||
|                 Observable.just(false) |                 Observable.just(false) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Appends a new section to the wiki page | ||||||
|  |      * @param pageTitle   Title of the page to edit | ||||||
|  |      * @param sectionTitle Title of the new section that needs to be created | ||||||
|  |      * @param sectionText  The page content that is to be added to the section | ||||||
|  |      * @param summary     Edit summary | ||||||
|  |      * @return whether the edit was successful | ||||||
|  |      */ | ||||||
|  |     fun createNewSection( | ||||||
|  |         pageTitle: String, | ||||||
|  |         sectionTitle: String, | ||||||
|  |         sectionText: String, | ||||||
|  |         summary: String, | ||||||
|  |     ): Observable<Boolean> = | ||||||
|  |         try { | ||||||
|  |             pageEditInterface | ||||||
|  |                 .postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking()) | ||||||
|  |                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||||
|  |         } catch (throwable: Throwable) { | ||||||
|  |             if (throwable is InvalidLoginTokenException) { | ||||||
|  |                 throw throwable | ||||||
|  |             } else { | ||||||
|  |                 Observable.just(false) | ||||||
|  |             } | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Set new labels to Wikibase server of commons |      * Set new labels to Wikibase server of commons | ||||||
|  | @ -86,11 +160,20 @@ class PageEditClient( | ||||||
|      * @param value label |      * @param value label | ||||||
|      * @return 1 when the edit was successful |      * @return 1 when the edit was successful | ||||||
|      */ |      */ | ||||||
|     fun setCaptions(summary: String, title: String, |     fun setCaptions( | ||||||
|                     language: String, value: String) : Observable<Int>{ |         summary: String, | ||||||
|         return try { |         title: String, | ||||||
|             pageEditInterface.postCaptions(summary, title, language, |         language: String, | ||||||
|                 value, csrfTokenClient.getTokenBlocking() |         value: String, | ||||||
|  |     ): Observable<Int> = | ||||||
|  |         try { | ||||||
|  |             pageEditInterface | ||||||
|  |                 .postCaptions( | ||||||
|  |                     summary, | ||||||
|  |                     title, | ||||||
|  |                     language, | ||||||
|  |                     value, | ||||||
|  |                     csrfTokenClient.getTokenBlocking(), | ||||||
|                 ).map { it.success } |                 ).map { it.success } | ||||||
|         } catch (throwable: Throwable) { |         } catch (throwable: Throwable) { | ||||||
|             if (throwable is InvalidLoginTokenException) { |             if (throwable is InvalidLoginTokenException) { | ||||||
|  | @ -99,16 +182,20 @@ class PageEditClient( | ||||||
|                 Observable.just(0) |                 Observable.just(0) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Get whole WikiText of required file |      * Get whole WikiText of required file | ||||||
|      * @param title : Name of the file |      * @param title : Name of the file | ||||||
|      * @return Observable<MwQueryResult> |      * @return Observable<MwQueryResult> | ||||||
|      */ |      */ | ||||||
|     fun getCurrentWikiText(title: String): Single<String?> { |     fun getCurrentWikiText(title: String): Single<String?> = | ||||||
|         return pageEditInterface.getWikiText(title).map { |         pageEditInterface.getWikiText(title).map { | ||||||
|             it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content() |             it | ||||||
|         } |                 .query() | ||||||
|  |                 ?.pages() | ||||||
|  |                 ?.get(0) | ||||||
|  |                 ?.revisions() | ||||||
|  |                 ?.get(0) | ||||||
|  |                 ?.content() | ||||||
|         } |         } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,10 +3,15 @@ package fr.free.nrw.commons.actions | ||||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | 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.Entities | ||||||
| import fr.free.nrw.commons.wikidata.model.edit.Edit | import fr.free.nrw.commons.wikidata.model.edit.Edit | ||||||
|  | import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||||
| import io.reactivex.Observable | import io.reactivex.Observable | ||||||
| import io.reactivex.Single | import io.reactivex.Single | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | import retrofit2.http.Field | ||||||
| import retrofit2.http.* | import retrofit2.http.FormUrlEncoded | ||||||
|  | import retrofit2.http.GET | ||||||
|  | import retrofit2.http.Headers | ||||||
|  | import retrofit2.http.POST | ||||||
|  | import retrofit2.http.Query | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * This interface facilitates wiki commons page editing services to the Networking module |  * This interface facilitates wiki commons page editing services to the Networking module | ||||||
|  | @ -33,7 +38,34 @@ interface PageEditInterface { | ||||||
|         @Field("summary") summary: String, |         @Field("summary") summary: String, | ||||||
|         @Field("text") text: String, |         @Field("text") text: String, | ||||||
|         // NOTE: This csrf shold always be sent as the last field of form data |         // NOTE: This csrf shold always be sent as the last field of form data | ||||||
|         @Field("token") token: String |         @Field("token") token: String, | ||||||
|  |     ): Observable<Edit> | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method creates or edits a page for nearby items. | ||||||
|  |      * | ||||||
|  |      * @param title           Title of the page to edit. Cannot be used together with pageid. | ||||||
|  |      * @param summary         Edit summary. Also used as the section title when section=new and sectiontitle is not set. | ||||||
|  |      * @param text            Text of the page. | ||||||
|  |      * @param contentformat   Format of the content (e.g., "text/x-wiki"). | ||||||
|  |      * @param contentmodel    Model of the content (e.g., "wikitext"). | ||||||
|  |      * @param minor           Whether the edit is a minor edit. | ||||||
|  |      * @param recreate        Whether to recreate the page if it does not exist. | ||||||
|  |      * @param token           A "csrf" token. This should always be sent as the last field of form data. | ||||||
|  |      */ | ||||||
|  |     @FormUrlEncoded | ||||||
|  |     @Headers("Cache-Control: no-cache") | ||||||
|  |     @POST(MW_API_PREFIX + "action=edit") | ||||||
|  |     fun postCreate( | ||||||
|  |         @Field("title") title: String, | ||||||
|  |         @Field("summary") summary: String, | ||||||
|  |         @Field("text") text: String, | ||||||
|  |         @Field("contentformat") contentformat: String, | ||||||
|  |         @Field("contentmodel") contentmodel: String, | ||||||
|  |         @Field("minor") minor: Boolean, | ||||||
|  |         @Field("recreate") recreate: Boolean, | ||||||
|  |         // NOTE: This csrf shold always be sent as the last field of form data | ||||||
|  |         @Field("token") token: String, | ||||||
|     ): Observable<Edit> |     ): Observable<Edit> | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -52,7 +84,7 @@ interface PageEditInterface { | ||||||
|         @Field("title") title: String, |         @Field("title") title: String, | ||||||
|         @Field("summary") summary: String, |         @Field("summary") summary: String, | ||||||
|         @Field("appendtext") appendText: String, |         @Field("appendtext") appendText: String, | ||||||
|         @Field("token") token: String |         @Field("token") token: String, | ||||||
|     ): Observable<Edit> |     ): Observable<Edit> | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -71,9 +103,19 @@ interface PageEditInterface { | ||||||
|         @Field("title") title: String, |         @Field("title") title: String, | ||||||
|         @Field("summary") summary: String, |         @Field("summary") summary: String, | ||||||
|         @Field("prependtext") prependText: String, |         @Field("prependtext") prependText: String, | ||||||
|         @Field("token") token: String |         @Field("token") token: String, | ||||||
|     ): Observable<Edit> |     ): 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 |     @FormUrlEncoded | ||||||
|     @Headers("Cache-Control: no-cache") |     @Headers("Cache-Control: no-cache") | ||||||
|  | @ -83,7 +125,7 @@ interface PageEditInterface { | ||||||
|         @Field("title") title: String, |         @Field("title") title: String, | ||||||
|         @Field("language") language: String, |         @Field("language") language: String, | ||||||
|         @Field("value") value: String, |         @Field("value") value: String, | ||||||
|         @Field("token") token: String |         @Field("token") token: String, | ||||||
|     ): Observable<Entities> |     ): Observable<Entities> | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -93,6 +135,6 @@ interface PageEditInterface { | ||||||
|      */ |      */ | ||||||
|     @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") |     @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") | ||||||
|     fun getWikiText( |     fun getWikiText( | ||||||
|         @Query("titles") title: String |         @Query("titles") title: String, | ||||||
|     ): Single<MwQueryResponse?> |     ): Single<MwQueryResponse?> | ||||||
| } | } | ||||||
|  | @ -1,11 +1,10 @@ | ||||||
| package fr.free.nrw.commons.actions | package fr.free.nrw.commons.actions | ||||||
| 
 | 
 | ||||||
| import fr.free.nrw.commons.CommonsApplication | import fr.free.nrw.commons.CommonsApplication | ||||||
| import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF |  | ||||||
| import io.reactivex.Observable |  | ||||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||||
| import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | ||||||
| import fr.free.nrw.commons.auth.login.LoginFailedException | import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF | ||||||
|  | import io.reactivex.Observable | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| import javax.inject.Named | import javax.inject.Named | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
|  | @ -15,34 +14,33 @@ import javax.inject.Singleton | ||||||
|  * Thanks are used by a user to show gratitude to another user for their contributions |  * Thanks are used by a user to show gratitude to another user for their contributions | ||||||
|  */ |  */ | ||||||
| @Singleton | @Singleton | ||||||
| class ThanksClient @Inject constructor( | class ThanksClient | ||||||
|  |     @Inject | ||||||
|  |     constructor( | ||||||
|         @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, |         @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, | ||||||
|     private val service: ThanksInterface |         private val service: ThanksInterface, | ||||||
|     ) { |     ) { | ||||||
|         /** |         /** | ||||||
|          * Thanks a user for a particular revision |          * Thanks a user for a particular revision | ||||||
|          * @param revisionId The revision ID the user would like to thank someone for |          * @param revisionId The revision ID the user would like to thank someone for | ||||||
|          * @return if thanks was successfully sent to intended recipient |          * @return if thanks was successfully sent to intended recipient | ||||||
|          */ |          */ | ||||||
|     fun thank(revisionId: Long): Observable<Boolean> { |         fun thank(revisionId: Long): Observable<Boolean> = | ||||||
|         return try { |             try { | ||||||
|             service.thank( |                 service | ||||||
|  |                     .thank( | ||||||
|                         revisionId.toString(), // Rev |                         revisionId.toString(), // Rev | ||||||
|                         null, // Log |                         null, // Log | ||||||
|                         csrfTokenClient.getTokenBlocking(), // Token |                         csrfTokenClient.getTokenBlocking(), // Token | ||||||
|                 CommonsApplication.getInstance().userAgent  // Source |                         CommonsApplication.instance.userAgent, // Source | ||||||
|             ).map { |                     ).map { mwThankPostResponse -> | ||||||
|                 mwThankPostResponse -> mwThankPostResponse.result?.success == 1 |                         mwThankPostResponse.result?.success == 1 | ||||||
|                     } |                     } | ||||||
|         } |             } catch (throwable: Throwable) { | ||||||
|         catch (throwable: Throwable) { |  | ||||||
|                 if (throwable is InvalidLoginTokenException) { |                 if (throwable is InvalidLoginTokenException) { | ||||||
|                     Observable.error(throwable) |                     Observable.error(throwable) | ||||||
|             } |                 } else { | ||||||
|             else { |  | ||||||
|                     Observable.just(false) |                     Observable.just(false) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -19,6 +19,6 @@ interface ThanksInterface { | ||||||
|         @Field("rev") rev: String?, |         @Field("rev") rev: String?, | ||||||
|         @Field("log") log: String?, |         @Field("log") log: String?, | ||||||
|         @Field("token") token: String, |         @Field("token") token: String, | ||||||
|         @Field("source") source: String? |         @Field("source") source: String?, | ||||||
|     ): Observable<MwThankPostResponse?> |     ): Observable<MwThankPostResponse?> | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,209 @@ | ||||||
|  | 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.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 = { | ||||||
|  |                             // TODO Redirect the user to login screen like we do when the user logout's | ||||||
|  |                             finish() | ||||||
|  |                         }, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxSize() | ||||||
|  |                             .padding(it) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @param url The initial URL which we are loading in the WebView. | ||||||
|  |      * @param successUrl The URL that, when reached, triggers the `onSuccess` callback. | ||||||
|  |      * @param onSuccess A callback that is invoked when the current url of webView is successUrl. | ||||||
|  |      * This is used when we want to close when the webView once a success url is hit. | ||||||
|  |      * @param modifier An optional [Modifier] to customize the layout or appearance of the WebView. | ||||||
|  |      */ | ||||||
|  |     @SuppressLint("SetJavaScriptEnabled") | ||||||
|  |     @Composable | ||||||
|  |     private fun WebViewComponent( | ||||||
|  |         url: String, | ||||||
|  |         successUrl: String, | ||||||
|  |         onSuccess: () -> Unit, | ||||||
|  |         modifier: Modifier = Modifier | ||||||
|  |     ) { | ||||||
|  |         val webView = remember { mutableStateOf<WebView?>(null) } | ||||||
|  |         AndroidView( | ||||||
|  |             modifier = modifier, | ||||||
|  |             factory = { | ||||||
|  |                 WebView(it).apply { | ||||||
|  |                     settings.apply { | ||||||
|  |                         javaScriptEnabled = true | ||||||
|  |                         domStorageEnabled = true | ||||||
|  |                         javaScriptCanOpenWindowsAutomatically = true | ||||||
|  | 
 | ||||||
|  |                     } | ||||||
|  |                     webViewClient = object : WebViewClient() { | ||||||
|  |                         override fun shouldOverrideUrlLoading( | ||||||
|  |                             view: WebView?, | ||||||
|  |                             request: WebResourceRequest? | ||||||
|  |                         ): Boolean { | ||||||
|  | 
 | ||||||
|  |                             request?.url?.let { url -> | ||||||
|  |                                 Timber.d("URL Loading: $url") | ||||||
|  |                                 if (url.toString() == successUrl) { | ||||||
|  |                                     Timber.d("Success URL detected. Closing WebView.") | ||||||
|  |                                     onSuccess() // Close the activity | ||||||
|  |                                     return true | ||||||
|  |                                 } | ||||||
|  |                                 return false | ||||||
|  |                             } | ||||||
|  |                             return false | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         override fun onPageFinished(view: WebView?, url: String?) { | ||||||
|  |                             super.onPageFinished(view, url) | ||||||
|  |                             setCookies(url.orEmpty()) | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     webChromeClient = object : WebChromeClient() { | ||||||
|  |                         override fun onConsoleMessage(message: ConsoleMessage): Boolean { | ||||||
|  |                             Timber.d("Console: ${message.message()} -- From line ${message.lineNumber()} of ${message.sourceId()}") | ||||||
|  |                             return true | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     loadUrl(url) | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             update = { | ||||||
|  |                 webView.value = it | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`. | ||||||
|  |      * | ||||||
|  |      * @param url The URL for which cookies need to be set. | ||||||
|  |      */ | ||||||
|  |     private fun setCookies(url: String) { | ||||||
|  |         CookieManager.getInstance().let { | ||||||
|  |             val cookies = cookieJar.loadForRequest(url.toHttpUrl()) | ||||||
|  |             for (cookie in cookies) { | ||||||
|  |                 it.setCookie(url, cookie.toString()) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         private const val VANISH_ACCOUNT_URL = "VanishAccountUrl" | ||||||
|  |         private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl" | ||||||
|  | 
 | ||||||
|  |         /** | ||||||
|  |          * Launch the WebViewActivity with the specified URL and success URL. | ||||||
|  |          * @param context The context from which the activity is launched. | ||||||
|  |          * @param url The initial URL to load in the WebView. | ||||||
|  |          * @param successUrl The URL that triggers the WebView to close when matched. | ||||||
|  |          */ | ||||||
|  |         fun showWebView( | ||||||
|  |             context: Context, | ||||||
|  |             url: String, | ||||||
|  |             successUrl: String | ||||||
|  |         ) { | ||||||
|  |             val intent = Intent( | ||||||
|  |                 context, | ||||||
|  |                 SingleWebViewActivity::class.java | ||||||
|  |             ).apply { | ||||||
|  |                 putExtra(VANISH_ACCOUNT_URL, url) | ||||||
|  |                 putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl) | ||||||
|  |             } | ||||||
|  |             context.startActivity(intent) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @ -1,44 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.accounts.Account; |  | ||||||
| import android.accounts.AccountManager; |  | ||||||
| import android.content.Context; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| public class AccountUtil { |  | ||||||
| 
 |  | ||||||
|     public static final String AUTH_TOKEN_TYPE = "CommonsAndroid"; |  | ||||||
| 
 |  | ||||||
|     public AccountUtil() { |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @return Account|null |  | ||||||
|      */ |  | ||||||
|     @Nullable |  | ||||||
|     public static Account account(Context context) { |  | ||||||
|         try { |  | ||||||
|             Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE); |  | ||||||
|             if (accounts.length > 0) { |  | ||||||
|                 return accounts[0]; |  | ||||||
|             } |  | ||||||
|         } catch (SecurityException e) { |  | ||||||
|             Timber.e(e); |  | ||||||
|         } |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     public static String getUserName(Context context) { |  | ||||||
|         Account account = account(context); |  | ||||||
|         return account == null ? null : account.name; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private static AccountManager accountManager(Context context) { |  | ||||||
|         return AccountManager.get(context); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										24
									
								
								app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.accounts.Account | ||||||
|  | import android.accounts.AccountManager | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.annotation.VisibleForTesting | ||||||
|  | import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE | ||||||
|  | import timber.log.Timber | ||||||
|  | 
 | ||||||
|  | const val AUTH_TOKEN_TYPE: String = "CommonsAndroid" | ||||||
|  | 
 | ||||||
|  | fun getUserName(context: Context): String? { | ||||||
|  |     return account(context)?.name | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @VisibleForTesting | ||||||
|  | fun account(context: Context): Account? = try { | ||||||
|  |     val accountManager = AccountManager.get(context) | ||||||
|  |     val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE) | ||||||
|  |     if (accounts.isNotEmpty()) accounts[0] else null | ||||||
|  | } catch (e: SecurityException) { | ||||||
|  |     Timber.e(e) | ||||||
|  |     null | ||||||
|  | } | ||||||
|  | @ -1,456 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.accounts.AccountAuthenticatorActivity; |  | ||||||
| import android.app.ProgressDialog; |  | ||||||
| import android.content.Context; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.net.Uri; |  | ||||||
| import android.os.Bundle; |  | ||||||
| import android.text.Editable; |  | ||||||
| import android.text.TextWatcher; |  | ||||||
| import android.view.KeyEvent; |  | ||||||
| import android.view.MenuInflater; |  | ||||||
| import android.view.MenuItem; |  | ||||||
| import android.view.View; |  | ||||||
| import android.view.ViewGroup; |  | ||||||
| import android.view.inputmethod.InputMethodManager; |  | ||||||
| 
 |  | ||||||
| import android.widget.TextView; |  | ||||||
| import androidx.annotation.ColorRes; |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| import androidx.annotation.StringRes; |  | ||||||
| import androidx.appcompat.app.AlertDialog; |  | ||||||
| import androidx.appcompat.app.AppCompatDelegate; |  | ||||||
| import androidx.core.app.NavUtils; |  | ||||||
| import androidx.core.content.ContextCompat; |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginClient; |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginResult; |  | ||||||
| import fr.free.nrw.commons.databinding.ActivityLoginBinding; |  | ||||||
| import fr.free.nrw.commons.utils.ActivityUtils; |  | ||||||
| import java.util.Locale; |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginCallback; |  | ||||||
| 
 |  | ||||||
| import java.util.Objects; |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.Utils; |  | ||||||
| import fr.free.nrw.commons.contributions.MainActivity; |  | ||||||
| import fr.free.nrw.commons.di.ApplicationlessInjection; |  | ||||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; |  | ||||||
| import fr.free.nrw.commons.utils.ConfigUtils; |  | ||||||
| import fr.free.nrw.commons.utils.SystemThemeUtils; |  | ||||||
| import fr.free.nrw.commons.utils.ViewUtil; |  | ||||||
| import io.reactivex.disposables.CompositeDisposable; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| import static android.view.KeyEvent.KEYCODE_ENTER; |  | ||||||
| import static android.view.View.VISIBLE; |  | ||||||
| import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE; |  | ||||||
| import static fr.free.nrw.commons.CommonsApplication.loginMessageIntentKey; |  | ||||||
| import static fr.free.nrw.commons.CommonsApplication.loginUsernameIntentKey; |  | ||||||
| 
 |  | ||||||
| public class LoginActivity extends AccountAuthenticatorActivity { |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     SessionManager sessionManager; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     @Named("default_preferences") |  | ||||||
|     JsonKvStore applicationKvStore; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     LoginClient loginClient; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     SystemThemeUtils systemThemeUtils; |  | ||||||
| 
 |  | ||||||
|     private ActivityLoginBinding binding; |  | ||||||
|     ProgressDialog progressDialog; |  | ||||||
|     private AppCompatDelegate delegate; |  | ||||||
|     private LoginTextWatcher textWatcher = new LoginTextWatcher(); |  | ||||||
|     private CompositeDisposable compositeDisposable = new CompositeDisposable(); |  | ||||||
|     final  String saveProgressDailog="ProgressDailog_state"; |  | ||||||
|     final String saveErrorMessage ="errorMessage"; |  | ||||||
|     final String saveUsername="username"; |  | ||||||
|     final  String savePassword="password"; |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onCreate(Bundle savedInstanceState) { |  | ||||||
|         super.onCreate(savedInstanceState); |  | ||||||
|         ApplicationlessInjection |  | ||||||
|                 .getInstance(this.getApplicationContext()) |  | ||||||
|                 .getCommonsApplicationComponent() |  | ||||||
|                 .inject(this); |  | ||||||
| 
 |  | ||||||
|         boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode(); |  | ||||||
|         setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme); |  | ||||||
|         getDelegate().installViewFactory(); |  | ||||||
|         getDelegate().onCreate(savedInstanceState); |  | ||||||
| 
 |  | ||||||
|         binding = ActivityLoginBinding.inflate(getLayoutInflater()); |  | ||||||
|         setContentView(binding.getRoot()); |  | ||||||
| 
 |  | ||||||
|         String message = getIntent().getStringExtra(loginMessageIntentKey); |  | ||||||
|         String username = getIntent().getStringExtra(loginUsernameIntentKey); |  | ||||||
| 
 |  | ||||||
|         binding.loginUsername.addTextChangedListener(textWatcher); |  | ||||||
|         binding.loginPassword.addTextChangedListener(textWatcher); |  | ||||||
|         binding.loginTwoFactor.addTextChangedListener(textWatcher); |  | ||||||
| 
 |  | ||||||
|         binding.skipLogin.setOnClickListener(view -> skipLogin()); |  | ||||||
|         binding.forgotPassword.setOnClickListener(view -> forgotPassword()); |  | ||||||
|         binding.aboutPrivacyPolicy.setOnClickListener(view -> onPrivacyPolicyClicked()); |  | ||||||
|         binding.signUpButton.setOnClickListener(view -> signUp()); |  | ||||||
|         binding.loginButton.setOnClickListener(view -> performLogin()); |  | ||||||
| 
 |  | ||||||
|         binding.loginPassword.setOnEditorActionListener(this::onEditorAction); |  | ||||||
|         binding.loginPassword.setOnFocusChangeListener(this::onPasswordFocusChanged); |  | ||||||
| 
 |  | ||||||
|         if (ConfigUtils.isBetaFlavour()) { |  | ||||||
|             binding.loginCredentials.setText(getString(R.string.login_credential)); |  | ||||||
|         } else { |  | ||||||
|             binding.loginCredentials.setVisibility(View.GONE); |  | ||||||
|         } |  | ||||||
|         if (message != null) { |  | ||||||
|             showMessage(message, R.color.secondaryDarkColor); |  | ||||||
|         } |  | ||||||
|         if (username != null) { |  | ||||||
|             binding.loginUsername.setText(username); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     /** |  | ||||||
|      * Hides the keyboard if the user's focus is not on the password (hasFocus is false). |  | ||||||
|      * @param view The keyboard |  | ||||||
|      * @param hasFocus Set to true if the keyboard has focus |  | ||||||
|      */ |  | ||||||
|     void onPasswordFocusChanged(View view, boolean hasFocus) { |  | ||||||
|         if (!hasFocus) { |  | ||||||
|             ViewUtil.hideKeyboard(view); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) { |  | ||||||
|         if (binding.loginButton.isEnabled()) { |  | ||||||
|             if (actionId == IME_ACTION_DONE) { |  | ||||||
|                 performLogin(); |  | ||||||
|                 return true; |  | ||||||
|             } else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) { |  | ||||||
|                 performLogin(); |  | ||||||
|                 return true; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     protected void skipLogin() { |  | ||||||
|         new AlertDialog.Builder(this).setTitle(R.string.skip_login_title) |  | ||||||
|                 .setMessage(R.string.skip_login_message) |  | ||||||
|                 .setCancelable(false) |  | ||||||
|                 .setPositiveButton(R.string.yes, (dialog, which) -> { |  | ||||||
|                     dialog.cancel(); |  | ||||||
|                     performSkipLogin(); |  | ||||||
|                 }) |  | ||||||
|                 .setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel()) |  | ||||||
|                 .show(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     protected void forgotPassword() { |  | ||||||
|         Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     protected void onPrivacyPolicyClicked() { |  | ||||||
|         Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     protected void signUp() { |  | ||||||
|         Intent intent = new Intent(this, SignupActivity.class); |  | ||||||
|         startActivity(intent); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onPostCreate(Bundle savedInstanceState) { |  | ||||||
|         super.onPostCreate(savedInstanceState); |  | ||||||
|         getDelegate().onPostCreate(savedInstanceState); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onResume() { |  | ||||||
|         super.onResume(); |  | ||||||
| 
 |  | ||||||
|         if (sessionManager.getCurrentAccount() != null |  | ||||||
|                 && sessionManager.isUserLoggedIn()) { |  | ||||||
|             applicationKvStore.putBoolean("login_skipped", false); |  | ||||||
|             startMainActivity(); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (applicationKvStore.getBoolean("login_skipped", false)) { |  | ||||||
|             performSkipLogin(); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onDestroy() { |  | ||||||
|         compositeDisposable.clear(); |  | ||||||
|         try { |  | ||||||
|             // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method |  | ||||||
|             if (progressDialog != null && progressDialog.isShowing()) { |  | ||||||
|                 progressDialog.dismiss(); |  | ||||||
|             } |  | ||||||
|         } catch (Exception e) { |  | ||||||
|             e.printStackTrace(); |  | ||||||
|         } |  | ||||||
|         binding.loginUsername.removeTextChangedListener(textWatcher); |  | ||||||
|         binding.loginPassword.removeTextChangedListener(textWatcher); |  | ||||||
|         binding.loginTwoFactor.removeTextChangedListener(textWatcher); |  | ||||||
|         delegate.onDestroy(); |  | ||||||
|         if(null!=loginClient) { |  | ||||||
|             loginClient.cancel(); |  | ||||||
|         } |  | ||||||
|         binding = null; |  | ||||||
|         super.onDestroy(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void performLogin() { |  | ||||||
|         Timber.d("Login to start!"); |  | ||||||
|         final String username = Objects.requireNonNull(binding.loginUsername.getText()).toString(); |  | ||||||
|         final String password = Objects.requireNonNull(binding.loginPassword.getText()).toString(); |  | ||||||
|         final String twoFactorCode = Objects.requireNonNull(binding.loginTwoFactor.getText()).toString(); |  | ||||||
| 
 |  | ||||||
|         showLoggingProgressBar(); |  | ||||||
|         loginClient.doLogin(username, password, twoFactorCode, Locale.getDefault().getLanguage(), |  | ||||||
|             new LoginCallback() { |  | ||||||
|                 @Override |  | ||||||
|                 public void success(@NonNull LoginResult loginResult) { |  | ||||||
|                     runOnUiThread(()->{ |  | ||||||
|                         Timber.d("Login Success"); |  | ||||||
|                         hideProgress(); |  | ||||||
|                         onLoginSuccess(loginResult); |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 @Override |  | ||||||
|                 public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) { |  | ||||||
|                     runOnUiThread(()->{ |  | ||||||
|                         Timber.d("Requesting 2FA prompt"); |  | ||||||
|                         hideProgress(); |  | ||||||
|                         askUserForTwoFactorAuth(); |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 @Override |  | ||||||
|                 public void passwordResetPrompt(@Nullable String token) { |  | ||||||
|                     runOnUiThread(()->{ |  | ||||||
|                         Timber.d("Showing password reset prompt"); |  | ||||||
|                         hideProgress(); |  | ||||||
|                         showPasswordResetPrompt(); |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 @Override |  | ||||||
|                 public void error(@NonNull Throwable caught) { |  | ||||||
|                     runOnUiThread(()->{ |  | ||||||
|                         Timber.e(caught); |  | ||||||
|                         hideProgress(); |  | ||||||
|                         showMessageAndCancelDialog(caught.getLocalizedMessage()); |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     private void hideProgress() { |  | ||||||
|         progressDialog.dismiss(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void showPasswordResetPrompt() { |  | ||||||
|         showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This function is called when user skips the login. |  | ||||||
|      * It redirects the user to Explore Activity. |  | ||||||
|      */ |  | ||||||
|     private void performSkipLogin() { |  | ||||||
|         applicationKvStore.putBoolean("login_skipped", true); |  | ||||||
|         MainActivity.startYourself(this); |  | ||||||
|         finish(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void showLoggingProgressBar() { |  | ||||||
|         progressDialog = new ProgressDialog(this); |  | ||||||
|         progressDialog.setIndeterminate(true); |  | ||||||
|         progressDialog.setTitle(getString(R.string.logging_in_title)); |  | ||||||
|         progressDialog.setMessage(getString(R.string.logging_in_message)); |  | ||||||
|         progressDialog.setCanceledOnTouchOutside(false); |  | ||||||
|         progressDialog.show(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void onLoginSuccess(LoginResult loginResult) { |  | ||||||
|         compositeDisposable.clear(); |  | ||||||
|         sessionManager.setUserLoggedIn(true); |  | ||||||
|         sessionManager.updateAccount(loginResult); |  | ||||||
|         progressDialog.dismiss(); |  | ||||||
|         showSuccessAndDismissDialog(); |  | ||||||
|         startMainActivity(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onStart() { |  | ||||||
|         super.onStart(); |  | ||||||
|         delegate.onStart(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onStop() { |  | ||||||
|         super.onStop(); |  | ||||||
|         delegate.onStop(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onPostResume() { |  | ||||||
|         super.onPostResume(); |  | ||||||
|         getDelegate().onPostResume(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void setContentView(View view, ViewGroup.LayoutParams params) { |  | ||||||
|         getDelegate().setContentView(view, params); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public boolean onOptionsItemSelected(MenuItem item) { |  | ||||||
|         switch (item.getItemId()) { |  | ||||||
|             case android.R.id.home: |  | ||||||
|                 NavUtils.navigateUpFromSameTask(this); |  | ||||||
|                 return true; |  | ||||||
|         } |  | ||||||
|         return super.onOptionsItemSelected(item); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     @NonNull |  | ||||||
|     public MenuInflater getMenuInflater() { |  | ||||||
|         return getDelegate().getMenuInflater(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void askUserForTwoFactorAuth() { |  | ||||||
|         progressDialog.dismiss(); |  | ||||||
|         binding.twoFactorContainer.setVisibility(VISIBLE); |  | ||||||
|         binding.loginTwoFactor.setVisibility(VISIBLE); |  | ||||||
|         binding.loginTwoFactor.requestFocus(); |  | ||||||
|         InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); |  | ||||||
|         imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); |  | ||||||
|         showMessageAndCancelDialog(R.string.login_failed_2fa_needed); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void showMessageAndCancelDialog(@StringRes int resId) { |  | ||||||
|         showMessage(resId, R.color.secondaryDarkColor); |  | ||||||
|         if (progressDialog != null) { |  | ||||||
|             progressDialog.cancel(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void showMessageAndCancelDialog(String error) { |  | ||||||
|         showMessage(error, R.color.secondaryDarkColor); |  | ||||||
|         if (progressDialog != null) { |  | ||||||
|             progressDialog.cancel(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void showSuccessAndDismissDialog() { |  | ||||||
|         showMessage(R.string.login_success, R.color.primaryDarkColor); |  | ||||||
|         progressDialog.dismiss(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void startMainActivity() { |  | ||||||
|         ActivityUtils.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP); |  | ||||||
|         finish(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void showMessage(@StringRes int resId, @ColorRes int colorResId) { |  | ||||||
|         binding.errorMessage.setText(getString(resId)); |  | ||||||
|         binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId)); |  | ||||||
|         binding.errorMessageContainer.setVisibility(VISIBLE); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void showMessage(String message, @ColorRes int colorResId) { |  | ||||||
|         binding.errorMessage.setText(message); |  | ||||||
|         binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId)); |  | ||||||
|         binding.errorMessageContainer.setVisibility(VISIBLE); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private AppCompatDelegate getDelegate() { |  | ||||||
|         if (delegate == null) { |  | ||||||
|             delegate = AppCompatDelegate.create(this, null); |  | ||||||
|         } |  | ||||||
|         return delegate; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private class LoginTextWatcher implements TextWatcher { |  | ||||||
|         @Override |  | ||||||
|         public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) { |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override |  | ||||||
|         public void onTextChanged(CharSequence charSequence, int start, int count, int after) { |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override |  | ||||||
|         public void afterTextChanged(Editable editable) { |  | ||||||
|             boolean enabled = binding.loginUsername.getText().length() != 0 && |  | ||||||
|                 binding.loginPassword.getText().length() != 0 && |  | ||||||
|                 (BuildConfig.DEBUG || binding.loginTwoFactor.getText().length() != 0 || |  | ||||||
|                     binding.loginTwoFactor.getVisibility() != VISIBLE); |  | ||||||
|             binding.loginButton.setEnabled(enabled); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public static void startYourself(Context context) { |  | ||||||
|         Intent intent = new Intent(context, LoginActivity.class); |  | ||||||
|         context.startActivity(intent); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onSaveInstanceState(Bundle outState) { |  | ||||||
|         // if progressDialog is visible during the configuration change  then store state as  true else false so that |  | ||||||
|         // we maintain visibility of progressDailog after configuration change |  | ||||||
|         if(progressDialog!=null&&progressDialog.isShowing()) { |  | ||||||
|             outState.putBoolean(saveProgressDailog,true); |  | ||||||
|         } else { |  | ||||||
|             outState.putBoolean(saveProgressDailog,false); |  | ||||||
|         } |  | ||||||
|         outState.putString(saveErrorMessage,binding.errorMessage.getText().toString()); //Save the errorMessage |  | ||||||
|         outState.putString(saveUsername,getUsername()); // Save the username |  | ||||||
|         outState.putString(savePassword,getPassword()); // Save the password |  | ||||||
|     } |  | ||||||
|     private String getUsername() { |  | ||||||
|         return binding.loginUsername.getText().toString(); |  | ||||||
|     } |  | ||||||
|     private String getPassword(){ |  | ||||||
|         return  binding.loginPassword.getText().toString(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onRestoreInstanceState(final Bundle savedInstanceState) { |  | ||||||
|         super.onRestoreInstanceState(savedInstanceState); |  | ||||||
|         binding.loginUsername.setText(savedInstanceState.getString(saveUsername)); |  | ||||||
|         binding.loginPassword.setText(savedInstanceState.getString(savePassword)); |  | ||||||
|         if(savedInstanceState.getBoolean(saveProgressDailog)) { |  | ||||||
|             performLogin(); |  | ||||||
|         } |  | ||||||
|         String errorMessage=savedInstanceState.getString(saveErrorMessage); |  | ||||||
|         if(sessionManager.isUserLoggedIn()) { |  | ||||||
|             showMessage(R.string.login_success, R.color.primaryDarkColor); |  | ||||||
|         } else { |  | ||||||
|             showMessage(errorMessage, R.color.secondaryDarkColor); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										404
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,404 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.accounts.AccountAuthenticatorActivity | ||||||
|  | import android.app.ProgressDialog | ||||||
|  | import android.content.Context | ||||||
|  | import android.content.DialogInterface | ||||||
|  | import android.content.Intent | ||||||
|  | import android.net.Uri | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.view.KeyEvent | ||||||
|  | import android.view.MenuInflater | ||||||
|  | import android.view.MenuItem | ||||||
|  | import android.view.View | ||||||
|  | import android.view.ViewGroup | ||||||
|  | import android.view.inputmethod.EditorInfo | ||||||
|  | import android.view.inputmethod.InputMethodManager | ||||||
|  | import android.widget.TextView | ||||||
|  | import androidx.annotation.ColorRes | ||||||
|  | import androidx.annotation.StringRes | ||||||
|  | import androidx.annotation.VisibleForTesting | ||||||
|  | import androidx.appcompat.app.AlertDialog | ||||||
|  | import androidx.appcompat.app.AppCompatDelegate | ||||||
|  | import androidx.core.app.NavUtils | ||||||
|  | import androidx.core.content.ContextCompat | ||||||
|  | import fr.free.nrw.commons.BuildConfig | ||||||
|  | import fr.free.nrw.commons.CommonsApplication | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.Utils | ||||||
|  | import fr.free.nrw.commons.auth.login.LoginCallback | ||||||
|  | import fr.free.nrw.commons.auth.login.LoginClient | ||||||
|  | import fr.free.nrw.commons.auth.login.LoginResult | ||||||
|  | import fr.free.nrw.commons.contributions.MainActivity | ||||||
|  | import fr.free.nrw.commons.databinding.ActivityLoginBinding | ||||||
|  | import fr.free.nrw.commons.di.ApplicationlessInjection | ||||||
|  | import fr.free.nrw.commons.kvstore.JsonKvStore | ||||||
|  | import fr.free.nrw.commons.utils.AbstractTextWatcher | ||||||
|  | import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags | ||||||
|  | import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour | ||||||
|  | import fr.free.nrw.commons.utils.SystemThemeUtils | ||||||
|  | import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard | ||||||
|  | import io.reactivex.disposables.CompositeDisposable | ||||||
|  | import timber.log.Timber | ||||||
|  | import java.util.Locale | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | 
 | ||||||
|  | class LoginActivity : AccountAuthenticatorActivity() { | ||||||
|  |     @Inject | ||||||
|  |     lateinit var sessionManager: SessionManager | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     @field:Named("default_preferences") | ||||||
|  |     lateinit var applicationKvStore: JsonKvStore | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     lateinit var loginClient: LoginClient | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     lateinit var systemThemeUtils: SystemThemeUtils | ||||||
|  | 
 | ||||||
|  |     private var binding: ActivityLoginBinding? = null | ||||||
|  |     private var progressDialog: ProgressDialog? = null | ||||||
|  |     private val textWatcher = AbstractTextWatcher(::onTextChanged) | ||||||
|  |     private val compositeDisposable = CompositeDisposable() | ||||||
|  |     private val delegate: AppCompatDelegate by lazy { | ||||||
|  |         AppCompatDelegate.create(this, null) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  |         ApplicationlessInjection | ||||||
|  |             .getInstance(this.applicationContext) | ||||||
|  |             .commonsApplicationComponent | ||||||
|  |             .inject(this) | ||||||
|  | 
 | ||||||
|  |         val isDarkTheme = systemThemeUtils.isDeviceInNightMode() | ||||||
|  |         setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme) | ||||||
|  |         delegate.installViewFactory() | ||||||
|  |         delegate.onCreate(savedInstanceState) | ||||||
|  | 
 | ||||||
|  |         binding = ActivityLoginBinding.inflate(layoutInflater) | ||||||
|  |         with(binding!!) { | ||||||
|  |             setContentView(root) | ||||||
|  | 
 | ||||||
|  |             loginUsername.addTextChangedListener(textWatcher) | ||||||
|  |             loginPassword.addTextChangedListener(textWatcher) | ||||||
|  |             loginTwoFactor.addTextChangedListener(textWatcher) | ||||||
|  | 
 | ||||||
|  |             skipLogin.setOnClickListener { skipLogin() } | ||||||
|  |             forgotPassword.setOnClickListener { forgotPassword() } | ||||||
|  |             aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() } | ||||||
|  |             signUpButton.setOnClickListener { signUp() } | ||||||
|  |             loginButton.setOnClickListener { performLogin() } | ||||||
|  |             loginPassword.setOnEditorActionListener(::onEditorAction) | ||||||
|  | 
 | ||||||
|  |             loginPassword.onFocusChangeListener = | ||||||
|  |                 View.OnFocusChangeListener(::onPasswordFocusChanged) | ||||||
|  | 
 | ||||||
|  |             if (isBetaFlavour) { | ||||||
|  |                 loginCredentials.text = getString(R.string.login_credential) | ||||||
|  |             } else { | ||||||
|  |                 loginCredentials.visibility = View.GONE | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let { | ||||||
|  |                 showMessage(it, R.color.secondaryDarkColor) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let { | ||||||
|  |                 loginUsername.setText(it) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onPostCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onPostCreate(savedInstanceState) | ||||||
|  |         delegate.onPostCreate(savedInstanceState) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onResume() { | ||||||
|  |         super.onResume() | ||||||
|  | 
 | ||||||
|  |         if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) { | ||||||
|  |             applicationKvStore.putBoolean("login_skipped", false) | ||||||
|  |             startMainActivity() | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (applicationKvStore.getBoolean("login_skipped", false)) { | ||||||
|  |             performSkipLogin() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onDestroy() { | ||||||
|  |         compositeDisposable.clear() | ||||||
|  |         try { | ||||||
|  |             // To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method | ||||||
|  |             if (progressDialog?.isShowing == true) { | ||||||
|  |                 progressDialog!!.dismiss() | ||||||
|  |             } | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             e.printStackTrace() | ||||||
|  |         } | ||||||
|  |         with(binding!!) { | ||||||
|  |             loginUsername.removeTextChangedListener(textWatcher) | ||||||
|  |             loginPassword.removeTextChangedListener(textWatcher) | ||||||
|  |             loginTwoFactor.removeTextChangedListener(textWatcher) | ||||||
|  |         } | ||||||
|  |         delegate.onDestroy() | ||||||
|  |         loginClient.cancel() | ||||||
|  |         binding = null | ||||||
|  |         super.onDestroy() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onStart() { | ||||||
|  |         super.onStart() | ||||||
|  |         delegate.onStart() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onStop() { | ||||||
|  |         super.onStop() | ||||||
|  |         delegate.onStop() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onPostResume() { | ||||||
|  |         super.onPostResume() | ||||||
|  |         delegate.onPostResume() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun setContentView(view: View, params: ViewGroup.LayoutParams) { | ||||||
|  |         delegate.setContentView(view, params) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||||
|  |         when (item.itemId) { | ||||||
|  |             android.R.id.home -> { | ||||||
|  |                 NavUtils.navigateUpFromSameTask(this) | ||||||
|  |                 return true | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return super.onOptionsItemSelected(item) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onSaveInstanceState(outState: Bundle) { | ||||||
|  |         // if progressDialog is visible during the configuration change  then store state as  true else false so that | ||||||
|  |         // we maintain visibility of progressDialog after configuration change | ||||||
|  |         if (progressDialog != null && progressDialog!!.isShowing) { | ||||||
|  |             outState.putBoolean(SAVE_PROGRESS_DIALOG, true) | ||||||
|  |         } else { | ||||||
|  |             outState.putBoolean(SAVE_PROGRESS_DIALOG, false) | ||||||
|  |         } | ||||||
|  |         outState.putString( | ||||||
|  |             SAVE_ERROR_MESSAGE, | ||||||
|  |             binding!!.errorMessage.text.toString() | ||||||
|  |         ) //Save the errorMessage | ||||||
|  |         outState.putString( | ||||||
|  |             SAVE_USERNAME, | ||||||
|  |             binding!!.loginUsername.text.toString() | ||||||
|  |         ) // Save the username | ||||||
|  |         outState.putString( | ||||||
|  |             SAVE_PASSWORD, | ||||||
|  |             binding!!.loginPassword.text.toString() | ||||||
|  |         ) // Save the password | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onRestoreInstanceState(savedInstanceState: Bundle) { | ||||||
|  |         super.onRestoreInstanceState(savedInstanceState) | ||||||
|  |         binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME)) | ||||||
|  |         binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD)) | ||||||
|  |         if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) { | ||||||
|  |             performLogin() | ||||||
|  |         } | ||||||
|  |         val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE) | ||||||
|  |         if (sessionManager.isUserLoggedIn) { | ||||||
|  |             showMessage(R.string.login_success, R.color.primaryDarkColor) | ||||||
|  |         } else { | ||||||
|  |             showMessage(errorMessage, R.color.secondaryDarkColor) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Hides the keyboard if the user's focus is not on the password (hasFocus is false). | ||||||
|  |      * @param view The keyboard | ||||||
|  |      * @param hasFocus Set to true if the keyboard has focus | ||||||
|  |      */ | ||||||
|  |     private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) { | ||||||
|  |         if (!hasFocus) { | ||||||
|  |             hideKeyboard(view) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) = | ||||||
|  |         if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) { | ||||||
|  |             performLogin() | ||||||
|  |             true | ||||||
|  |         } else false | ||||||
|  | 
 | ||||||
|  |     private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) = | ||||||
|  |         actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER | ||||||
|  | 
 | ||||||
|  |     private fun skipLogin() { | ||||||
|  |         AlertDialog.Builder(this) | ||||||
|  |             .setTitle(R.string.skip_login_title) | ||||||
|  |             .setMessage(R.string.skip_login_message) | ||||||
|  |             .setCancelable(false) | ||||||
|  |             .setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int -> | ||||||
|  |                 dialog.cancel() | ||||||
|  |                 performSkipLogin() | ||||||
|  |             } | ||||||
|  |             .setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int -> | ||||||
|  |                 dialog.cancel() | ||||||
|  |             } | ||||||
|  |             .show() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun forgotPassword() = | ||||||
|  |         Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL)) | ||||||
|  | 
 | ||||||
|  |     private fun onPrivacyPolicyClicked() = | ||||||
|  |         Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)) | ||||||
|  | 
 | ||||||
|  |     private fun signUp() = | ||||||
|  |         startActivity(Intent(this, SignupActivity::class.java)) | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun performLogin() { | ||||||
|  |         Timber.d("Login to start!") | ||||||
|  |         val username = binding!!.loginUsername.text.toString() | ||||||
|  |         val password = binding!!.loginPassword.text.toString() | ||||||
|  |         val twoFactorCode = binding!!.loginTwoFactor.text.toString() | ||||||
|  | 
 | ||||||
|  |         showLoggingProgressBar() | ||||||
|  |         loginClient.doLogin(username, | ||||||
|  |             password, | ||||||
|  |             twoFactorCode, | ||||||
|  |             Locale.getDefault().language, | ||||||
|  |             object : LoginCallback { | ||||||
|  |                 override fun success(loginResult: LoginResult) = runOnUiThread { | ||||||
|  |                     Timber.d("Login Success") | ||||||
|  |                     progressDialog!!.dismiss() | ||||||
|  |                     onLoginSuccess(loginResult) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 override fun twoFactorPrompt(caught: Throwable, token: String?) = runOnUiThread { | ||||||
|  |                     Timber.d("Requesting 2FA prompt") | ||||||
|  |                     progressDialog!!.dismiss() | ||||||
|  |                     askUserForTwoFactorAuth() | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 override fun passwordResetPrompt(token: String?) = runOnUiThread { | ||||||
|  |                     Timber.d("Showing password reset prompt") | ||||||
|  |                     progressDialog!!.dismiss() | ||||||
|  |                     showPasswordResetPrompt() | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 override fun error(caught: Throwable) = runOnUiThread { | ||||||
|  |                     Timber.e(caught) | ||||||
|  |                     progressDialog!!.dismiss() | ||||||
|  |                     showMessageAndCancelDialog(caught.localizedMessage ?: "") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showPasswordResetPrompt() = | ||||||
|  |         showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword)) | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This function is called when user skips the login. | ||||||
|  |      * It redirects the user to Explore Activity. | ||||||
|  |      */ | ||||||
|  |     private fun performSkipLogin() { | ||||||
|  |         applicationKvStore.putBoolean("login_skipped", true) | ||||||
|  |         MainActivity.startYourself(this) | ||||||
|  |         finish() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showLoggingProgressBar() { | ||||||
|  |         progressDialog = ProgressDialog(this).apply { | ||||||
|  |             isIndeterminate = true | ||||||
|  |             setTitle(getString(R.string.logging_in_title)) | ||||||
|  |             setMessage(getString(R.string.logging_in_message)) | ||||||
|  |             setCancelable(false) | ||||||
|  |         } | ||||||
|  |         progressDialog!!.show() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun onLoginSuccess(loginResult: LoginResult) { | ||||||
|  |         compositeDisposable.clear() | ||||||
|  |         sessionManager.setUserLoggedIn(true) | ||||||
|  |         sessionManager.updateAccount(loginResult) | ||||||
|  |         progressDialog!!.dismiss() | ||||||
|  |         showSuccessAndDismissDialog() | ||||||
|  |         startMainActivity() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getMenuInflater(): MenuInflater = | ||||||
|  |         delegate.menuInflater | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun askUserForTwoFactorAuth() { | ||||||
|  |         progressDialog!!.dismiss() | ||||||
|  |         with(binding!!) { | ||||||
|  |             twoFactorContainer.visibility = View.VISIBLE | ||||||
|  |             loginTwoFactor.visibility = View.VISIBLE | ||||||
|  |             loginTwoFactor.requestFocus() | ||||||
|  |         } | ||||||
|  |         val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager | ||||||
|  |         imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY) | ||||||
|  |         showMessageAndCancelDialog(R.string.login_failed_2fa_needed) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun showMessageAndCancelDialog(@StringRes resId: Int) { | ||||||
|  |         showMessage(resId, R.color.secondaryDarkColor) | ||||||
|  |         progressDialog?.cancel() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun showMessageAndCancelDialog(error: String) { | ||||||
|  |         showMessage(error, R.color.secondaryDarkColor) | ||||||
|  |         progressDialog?.cancel() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun showSuccessAndDismissDialog() { | ||||||
|  |         showMessage(R.string.login_success, R.color.primaryDarkColor) | ||||||
|  |         progressDialog!!.dismiss() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @VisibleForTesting | ||||||
|  |     fun startMainActivity() { | ||||||
|  |         startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP) | ||||||
|  |         finish() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) { | ||||||
|  |         errorMessage.text = getString(resId) | ||||||
|  |         errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId)) | ||||||
|  |         errorMessageContainer.visibility = View.VISIBLE | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) { | ||||||
|  |         errorMessage.text = message | ||||||
|  |         errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId)) | ||||||
|  |         errorMessageContainer.visibility = View.VISIBLE | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun onTextChanged(text: String) { | ||||||
|  |         val enabled = | ||||||
|  |             binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 && | ||||||
|  |                     (BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE) | ||||||
|  |         binding!!.loginButton.isEnabled = enabled | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         fun startYourself(context: Context) = | ||||||
|  |             context.startActivity(Intent(context, LoginActivity::class.java)) | ||||||
|  | 
 | ||||||
|  |         const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state" | ||||||
|  |         const val SAVE_ERROR_MESSAGE: String = "errorMessage" | ||||||
|  |         const val SAVE_USERNAME: String = "username" | ||||||
|  |         const val SAVE_PASSWORD: String = "password" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,148 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.accounts.Account; |  | ||||||
| import android.accounts.AccountManager; |  | ||||||
| import android.content.Context; |  | ||||||
| import android.os.Build; |  | ||||||
| import android.text.TextUtils; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginResult; |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import javax.inject.Singleton; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; |  | ||||||
| import io.reactivex.Completable; |  | ||||||
| import io.reactivex.Observable; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Manage the current logged in user session. |  | ||||||
|  */ |  | ||||||
| @Singleton |  | ||||||
| public class SessionManager { |  | ||||||
|     private final Context context; |  | ||||||
|     private Account currentAccount; // Unlike a savings account...  ;-) |  | ||||||
|     private JsonKvStore defaultKvStore; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     public SessionManager(Context context, |  | ||||||
|                           @Named("default_preferences") JsonKvStore defaultKvStore) { |  | ||||||
|         this.context = context; |  | ||||||
|         this.currentAccount = null; |  | ||||||
|         this.defaultKvStore = defaultKvStore; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private boolean createAccount(@NonNull String userName, @NonNull String password) { |  | ||||||
|         Account account = getCurrentAccount(); |  | ||||||
|         if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) { |  | ||||||
|             removeAccount(); |  | ||||||
|             account = new Account(userName, BuildConfig.ACCOUNT_TYPE); |  | ||||||
|             return accountManager().addAccountExplicitly(account, password, null); |  | ||||||
|         } |  | ||||||
|         return true; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void removeAccount() { |  | ||||||
|         Account account = getCurrentAccount(); |  | ||||||
|         if (account != null) { |  | ||||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { |  | ||||||
|                 accountManager().removeAccountExplicitly(account); |  | ||||||
|             } else { |  | ||||||
|                 //noinspection deprecation |  | ||||||
|                 accountManager().removeAccount(account, null, null); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void updateAccount(LoginResult result) { |  | ||||||
|         boolean accountCreated = createAccount(result.getUserName(), result.getPassword()); |  | ||||||
|         if (accountCreated) { |  | ||||||
|             setPassword(result.getPassword()); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void setPassword(@NonNull String password) { |  | ||||||
|         Account account = getCurrentAccount(); |  | ||||||
|         if (account != null) { |  | ||||||
|             accountManager().setPassword(account, password); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @return Account|null |  | ||||||
|      */ |  | ||||||
|     @Nullable |  | ||||||
|     public Account getCurrentAccount() { |  | ||||||
|         if (currentAccount == null) { |  | ||||||
|             AccountManager accountManager = AccountManager.get(context); |  | ||||||
|             Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE); |  | ||||||
|             if (allAccounts.length != 0) { |  | ||||||
|                 currentAccount = allAccounts[0]; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return currentAccount; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public boolean doesAccountExist() { |  | ||||||
|         return getCurrentAccount() != null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     public String getUserName() { |  | ||||||
|         Account account = getCurrentAccount(); |  | ||||||
|         return account == null ? null : account.name; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     public String getPassword() { |  | ||||||
|         Account account = getCurrentAccount(); |  | ||||||
|         return account == null ? null : accountManager().getPassword(account); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private AccountManager accountManager() { |  | ||||||
|         return AccountManager.get(context); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public boolean isUserLoggedIn() { |  | ||||||
|         return defaultKvStore.getBoolean("isUserLoggedIn", false); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     void setUserLoggedIn(boolean isLoggedIn) { |  | ||||||
|         defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void forceLogin(Context context) { |  | ||||||
|         if (context != null) { |  | ||||||
|             LoginActivity.startYourself(context); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * 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); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										95
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,95 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.accounts.Account | ||||||
|  | import android.accounts.AccountManager | ||||||
|  | import android.content.Context | ||||||
|  | import android.os.Build | ||||||
|  | import android.text.TextUtils | ||||||
|  | import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE | ||||||
|  | import fr.free.nrw.commons.auth.login.LoginResult | ||||||
|  | import fr.free.nrw.commons.kvstore.JsonKvStore | ||||||
|  | import io.reactivex.Completable | ||||||
|  | import io.reactivex.Observable | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | import javax.inject.Singleton | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Manage the current logged in user session. | ||||||
|  |  */ | ||||||
|  | @Singleton | ||||||
|  | class SessionManager @Inject constructor( | ||||||
|  |     private val context: Context, | ||||||
|  |     @param:Named("default_preferences") private val defaultKvStore: JsonKvStore | ||||||
|  | ) { | ||||||
|  |     private val accountManager: AccountManager get() = AccountManager.get(context) | ||||||
|  | 
 | ||||||
|  |     private var _currentAccount: Account? = null // Unlike a savings account...  ;-) | ||||||
|  |     val currentAccount: Account? get() { | ||||||
|  |         if (_currentAccount == null) { | ||||||
|  |             val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE) | ||||||
|  |             if (allAccounts.isNotEmpty()) { | ||||||
|  |                 _currentAccount = allAccounts[0] | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return _currentAccount | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     val userName: String? | ||||||
|  |         get() = currentAccount?.name | ||||||
|  | 
 | ||||||
|  |     var password: String? | ||||||
|  |         get() = currentAccount?.let { accountManager.getPassword(it) } | ||||||
|  |         private set(value) { | ||||||
|  |             currentAccount?.let { accountManager.setPassword(it, value) } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     val isUserLoggedIn: Boolean | ||||||
|  |         get() = defaultKvStore.getBoolean("isUserLoggedIn", false) | ||||||
|  | 
 | ||||||
|  |     fun updateAccount(result: LoginResult) { | ||||||
|  |         if (createAccount(result.userName!!, result.password!!)) { | ||||||
|  |             password = result.password | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun doesAccountExist(): Boolean = | ||||||
|  |         currentAccount != null | ||||||
|  | 
 | ||||||
|  |     fun setUserLoggedIn(isLoggedIn: Boolean) = | ||||||
|  |         defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn) | ||||||
|  | 
 | ||||||
|  |     fun forceLogin(context: Context?) = | ||||||
|  |         context?.let { LoginActivity.startYourself(it) } | ||||||
|  | 
 | ||||||
|  |     fun getPreference(key: String): Boolean = | ||||||
|  |         defaultKvStore.getBoolean(key) | ||||||
|  | 
 | ||||||
|  |     fun logout(): Completable = Completable.fromObservable( | ||||||
|  |         Observable.empty<Any>() | ||||||
|  |             .doOnComplete { | ||||||
|  |                 removeAccount() | ||||||
|  |                 _currentAccount = null | ||||||
|  |             } | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private fun createAccount(userName: String, password: String): Boolean { | ||||||
|  |         var account = currentAccount | ||||||
|  |         if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) { | ||||||
|  |             removeAccount() | ||||||
|  |             account = Account(userName, ACCOUNT_TYPE) | ||||||
|  |             return accountManager.addAccountExplicitly(account, password, null) | ||||||
|  |         } | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun removeAccount() { | ||||||
|  |         currentAccount?.let { | ||||||
|  |             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { | ||||||
|  |                 accountManager.removeAccountExplicitly(it) | ||||||
|  |             } else { | ||||||
|  |                 accountManager.removeAccount(it, null, null) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,82 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.content.res.Configuration; |  | ||||||
| import android.os.Build; |  | ||||||
| import android.os.Bundle; |  | ||||||
| import android.webkit.WebSettings; |  | ||||||
| import android.webkit.WebView; |  | ||||||
| import android.webkit.WebViewClient; |  | ||||||
| import android.widget.Toast; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.theme.BaseActivity; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| public class SignupActivity extends BaseActivity { |  | ||||||
| 
 |  | ||||||
|     private WebView webView; |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onCreate(Bundle savedInstanceState) { |  | ||||||
|         super.onCreate(savedInstanceState); |  | ||||||
|         Timber.d("Signup Activity started"); |  | ||||||
| 
 |  | ||||||
|         webView = new WebView(this); |  | ||||||
|         setContentView(webView); |  | ||||||
| 
 |  | ||||||
|         webView.setWebViewClient(new MyWebViewClient()); |  | ||||||
|         WebSettings webSettings = webView.getSettings(); |  | ||||||
|         /*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can |  | ||||||
|          trust Wikimedia's site... right?*/ |  | ||||||
|         webSettings.setJavaScriptEnabled(true); |  | ||||||
| 
 |  | ||||||
|         webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private class MyWebViewClient extends WebViewClient { |  | ||||||
|         @Override |  | ||||||
|         public boolean shouldOverrideUrlLoading(WebView view, String url) { |  | ||||||
|             if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) { |  | ||||||
|                 //Signup success, so clear cookies, notify user, and load LoginActivity again |  | ||||||
|                 Timber.d("Overriding URL %s", url); |  | ||||||
| 
 |  | ||||||
|                 Toast toast = Toast.makeText(SignupActivity.this, |  | ||||||
|                         R.string.account_created, Toast.LENGTH_LONG); |  | ||||||
|                 toast.show(); |  | ||||||
|                 // terminate on task completion. |  | ||||||
|                 finish(); |  | ||||||
|                 return true; |  | ||||||
|             } else { |  | ||||||
|                 //If user clicks any other links in the webview |  | ||||||
|                 Timber.d("Not overriding URL, URL is: %s", url); |  | ||||||
|                 return false; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onBackPressed() { |  | ||||||
|         if (webView.canGoBack()) { |  | ||||||
|             webView.goBack(); |  | ||||||
|         } else { |  | ||||||
|             super.onBackPressed(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Known bug in androidx.appcompat library version 1.1.0 being tracked here |  | ||||||
|      * https://issuetracker.google.com/issues/141132133 |  | ||||||
|      * App tries to put light/dark theme to webview and crashes in the process |  | ||||||
|      * This code tries to prevent applying the theme when sdk is between api 21 to 25 |  | ||||||
|      * @param overrideConfiguration |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void applyOverrideConfiguration(final Configuration overrideConfiguration) { |  | ||||||
|         if (Build.VERSION.SDK_INT <= 25 && |  | ||||||
|             (getResources().getConfiguration().uiMode == getApplicationContext().getResources().getConfiguration().uiMode)) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|         super.applyOverrideConfiguration(overrideConfiguration); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										75
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.annotation.SuppressLint | ||||||
|  | import android.content.res.Configuration | ||||||
|  | import android.os.Build | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.webkit.WebView | ||||||
|  | import android.webkit.WebViewClient | ||||||
|  | import android.widget.Toast | ||||||
|  | import fr.free.nrw.commons.BuildConfig | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.theme.BaseActivity | ||||||
|  | import timber.log.Timber | ||||||
|  | 
 | ||||||
|  | class SignupActivity : BaseActivity() { | ||||||
|  |     private var webView: WebView? = null | ||||||
|  | 
 | ||||||
|  |     @SuppressLint("SetJavaScriptEnabled") | ||||||
|  |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  |         Timber.d("Signup Activity started") | ||||||
|  | 
 | ||||||
|  |         webView = WebView(this) | ||||||
|  |         with(webView!!) { | ||||||
|  |             setContentView(this) | ||||||
|  |             webViewClient = MyWebViewClient() | ||||||
|  |             // Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can | ||||||
|  |             // trust Wikimedia's site... right? | ||||||
|  |             settings.javaScriptEnabled = true | ||||||
|  |             loadUrl(BuildConfig.SIGNUP_LANDING_URL) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onBackPressed() { | ||||||
|  |         if (webView!!.canGoBack()) { | ||||||
|  |             webView!!.goBack() | ||||||
|  |         } else { | ||||||
|  |             super.onBackPressed() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Known bug in androidx.appcompat library version 1.1.0 being tracked here | ||||||
|  |      * https://issuetracker.google.com/issues/141132133 | ||||||
|  |      * App tries to put light/dark theme to webview and crashes in the process | ||||||
|  |      * This code tries to prevent applying the theme when sdk is between api 21 to 25 | ||||||
|  |      */ | ||||||
|  |     override fun applyOverrideConfiguration(overrideConfiguration: Configuration) { | ||||||
|  |         if (Build.VERSION.SDK_INT <= 25 && | ||||||
|  |             (resources.configuration.uiMode == applicationContext.resources.configuration.uiMode) | ||||||
|  |         ) return | ||||||
|  |         super.applyOverrideConfiguration(overrideConfiguration) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private inner class MyWebViewClient : WebViewClient() { | ||||||
|  |         @Deprecated("Deprecated in Java") | ||||||
|  |         override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = | ||||||
|  |             if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) { | ||||||
|  |                 //Signup success, so clear cookies, notify user, and load LoginActivity again | ||||||
|  |                 Timber.d("Overriding URL %s", url) | ||||||
|  | 
 | ||||||
|  |                 Toast.makeText( | ||||||
|  |                     this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG | ||||||
|  |                 ).show() | ||||||
|  | 
 | ||||||
|  |                 // terminate on task completion. | ||||||
|  |                 finish() | ||||||
|  |                 true | ||||||
|  |             } else { | ||||||
|  |                 //If user clicks any other links in the webview | ||||||
|  |                 Timber.d("Not overriding URL, URL is: %s", url) | ||||||
|  |                 false | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,141 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.accounts.AbstractAccountAuthenticator; |  | ||||||
| import android.accounts.Account; |  | ||||||
| import android.accounts.AccountAuthenticatorResponse; |  | ||||||
| import android.accounts.AccountManager; |  | ||||||
| import android.accounts.NetworkErrorException; |  | ||||||
| import android.content.ContentResolver; |  | ||||||
| import android.content.Context; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.os.Bundle; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Handles WikiMedia commons account Authentication |  | ||||||
|  */ |  | ||||||
| public class WikiAccountAuthenticator extends AbstractAccountAuthenticator { |  | ||||||
|     private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY}; |  | ||||||
| 
 |  | ||||||
|     @NonNull |  | ||||||
|     private final Context context; |  | ||||||
| 
 |  | ||||||
|     public WikiAccountAuthenticator(@NonNull Context context) { |  | ||||||
|         super(context); |  | ||||||
|         this.context = context; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Provides Bundle with edited Account Properties  |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putString("test", "editProperties"); |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Bundle addAccount(@NonNull AccountAuthenticatorResponse response, |  | ||||||
|                              @NonNull String accountType, @Nullable String authTokenType, |  | ||||||
|                              @Nullable String[] requiredFeatures, @Nullable Bundle options) |  | ||||||
|             throws NetworkErrorException { |  | ||||||
|         // account type not supported returns bundle without loginActivity Intent, it just contains "test" key  |  | ||||||
|         if (!supportedAccountType(accountType)) { |  | ||||||
|             Bundle bundle = new Bundle(); |  | ||||||
|             bundle.putString("test", "addAccount"); |  | ||||||
|             return bundle; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return addAccount(response); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response, |  | ||||||
|                                      @NonNull Account account, @Nullable Bundle options) |  | ||||||
|             throws NetworkErrorException { |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putString("test", "confirmCredentials"); |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response, |  | ||||||
|                                @NonNull Account account, @NonNull String authTokenType, |  | ||||||
|                                @Nullable Bundle options) |  | ||||||
|             throws NetworkErrorException { |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putString("test", "getAuthToken"); |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     @Override |  | ||||||
|     public String getAuthTokenLabel(@NonNull String authTokenType) { |  | ||||||
|         return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     @Override |  | ||||||
|     public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response, |  | ||||||
|                                     @NonNull Account account, @Nullable String authTokenType, |  | ||||||
|                                     @Nullable Bundle options) |  | ||||||
|             throws NetworkErrorException { |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putString("test", "updateCredentials"); |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     @Override |  | ||||||
|     public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response, |  | ||||||
|                               @NonNull Account account, @NonNull String[] features) |  | ||||||
|             throws NetworkErrorException { |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false); |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private boolean supportedAccountType(@Nullable String type) { |  | ||||||
|         return BuildConfig.ACCOUNT_TYPE.equals(type); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Provides a bundle containing a Parcel  |  | ||||||
|      * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type) |  | ||||||
|      */ |  | ||||||
|     private Bundle addAccount(AccountAuthenticatorResponse response) { |  | ||||||
|         Intent intent = new Intent(context, LoginActivity.class); |  | ||||||
|         intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); |  | ||||||
| 
 |  | ||||||
|         Bundle bundle = new Bundle(); |  | ||||||
|         bundle.putParcelable(AccountManager.KEY_INTENT, intent); |  | ||||||
| 
 |  | ||||||
|         return bundle; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response, |  | ||||||
|                                            Account account) throws NetworkErrorException { |  | ||||||
|         Bundle result = super.getAccountRemovalAllowed(response, account); |  | ||||||
| 
 |  | ||||||
|         if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) |  | ||||||
|                 && !result.containsKey(AccountManager.KEY_INTENT)) { |  | ||||||
|             boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT); |  | ||||||
| 
 |  | ||||||
|             if (allowed) { |  | ||||||
|                 for (String auth : SYNC_AUTHORITIES) { |  | ||||||
|                     ContentResolver.cancelSync(account, auth); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,108 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.accounts.AbstractAccountAuthenticator | ||||||
|  | import android.accounts.Account | ||||||
|  | import android.accounts.AccountAuthenticatorResponse | ||||||
|  | import android.accounts.AccountManager | ||||||
|  | import android.accounts.NetworkErrorException | ||||||
|  | import android.content.ContentResolver | ||||||
|  | import android.content.Context | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.Bundle | ||||||
|  | import androidx.core.os.bundleOf | ||||||
|  | import fr.free.nrw.commons.BuildConfig | ||||||
|  | 
 | ||||||
|  | private val SYNC_AUTHORITIES = arrayOf( | ||||||
|  |     BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Handles WikiMedia commons account Authentication | ||||||
|  |  */ | ||||||
|  | class WikiAccountAuthenticator( | ||||||
|  |     private val context: Context | ||||||
|  | ) : AbstractAccountAuthenticator(context) { | ||||||
|  |     /** | ||||||
|  |      * Provides Bundle with edited Account Properties | ||||||
|  |      */ | ||||||
|  |     override fun editProperties( | ||||||
|  |         response: AccountAuthenticatorResponse, | ||||||
|  |         accountType: String | ||||||
|  |     ) = bundleOf("test" to "editProperties") | ||||||
|  | 
 | ||||||
|  |     // account type not supported returns bundle without loginActivity Intent, it just contains "test" key | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun addAccount( | ||||||
|  |         response: AccountAuthenticatorResponse, | ||||||
|  |         accountType: String, | ||||||
|  |         authTokenType: String?, | ||||||
|  |         requiredFeatures: Array<String>?, | ||||||
|  |         options: Bundle? | ||||||
|  |     ) = if (BuildConfig.ACCOUNT_TYPE == accountType) { | ||||||
|  |         addAccount(response) | ||||||
|  |     } else { | ||||||
|  |         bundleOf("test" to "addAccount") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun confirmCredentials( | ||||||
|  |         response: AccountAuthenticatorResponse, account: Account, options: Bundle? | ||||||
|  |     ) = bundleOf("test" to "confirmCredentials") | ||||||
|  | 
 | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun getAuthToken( | ||||||
|  |         response: AccountAuthenticatorResponse, | ||||||
|  |         account: Account, | ||||||
|  |         authTokenType: String, | ||||||
|  |         options: Bundle? | ||||||
|  |     ) = bundleOf("test" to "getAuthToken") | ||||||
|  | 
 | ||||||
|  |     override fun getAuthTokenLabel(authTokenType: String) = | ||||||
|  |         if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null | ||||||
|  | 
 | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun updateCredentials( | ||||||
|  |         response: AccountAuthenticatorResponse, | ||||||
|  |         account: Account, | ||||||
|  |         authTokenType: String?, | ||||||
|  |         options: Bundle? | ||||||
|  |     ) = bundleOf("test" to "updateCredentials") | ||||||
|  | 
 | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun hasFeatures( | ||||||
|  |         response: AccountAuthenticatorResponse, | ||||||
|  |         account: Account, features: Array<String> | ||||||
|  |     ) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false) | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Provides a bundle containing a Parcel | ||||||
|  |      * the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type) | ||||||
|  |      */ | ||||||
|  |     private fun addAccount(response: AccountAuthenticatorResponse): Bundle { | ||||||
|  |         val intent = Intent(context, LoginActivity::class.java) | ||||||
|  |             .putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) | ||||||
|  |         return bundleOf(AccountManager.KEY_INTENT to intent) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Throws(NetworkErrorException::class) | ||||||
|  |     override fun getAccountRemovalAllowed( | ||||||
|  |         response: AccountAuthenticatorResponse?, | ||||||
|  |         account: Account? | ||||||
|  |     ): Bundle { | ||||||
|  |         val result = super.getAccountRemovalAllowed(response, account) | ||||||
|  | 
 | ||||||
|  |         if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) | ||||||
|  |             && !result.containsKey(AccountManager.KEY_INTENT) | ||||||
|  |         ) { | ||||||
|  |             val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT) | ||||||
|  | 
 | ||||||
|  |             if (allowed) { | ||||||
|  |                 for (auth in SYNC_AUTHORITIES) { | ||||||
|  |                     ContentResolver.cancelSync(account, auth) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return result | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,31 +0,0 @@ | ||||||
| package fr.free.nrw.commons.auth; |  | ||||||
| 
 |  | ||||||
| import android.accounts.AbstractAccountAuthenticator; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.os.IBinder; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.di.CommonsDaggerService; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Handles the Auth service of the App, see AndroidManifests for details |  | ||||||
|  * (Uses Dagger 2 as injector) |  | ||||||
|  */ |  | ||||||
| public class WikiAccountAuthenticatorService extends CommonsDaggerService { |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     private AbstractAccountAuthenticator authenticator; |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onCreate() { |  | ||||||
|         super.onCreate(); |  | ||||||
|         authenticator = new WikiAccountAuthenticator(this); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Nullable |  | ||||||
|     @Override |  | ||||||
|     public IBinder onBind(Intent intent) { |  | ||||||
|         return authenticator == null ? null : authenticator.getIBinder(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,22 @@ | ||||||
|  | package fr.free.nrw.commons.auth | ||||||
|  | 
 | ||||||
|  | import android.accounts.AbstractAccountAuthenticator | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.IBinder | ||||||
|  | import fr.free.nrw.commons.di.CommonsDaggerService | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Handles the Auth service of the App, see AndroidManifests for details | ||||||
|  |  * (Uses Dagger 2 as injector) | ||||||
|  |  */ | ||||||
|  | class WikiAccountAuthenticatorService : CommonsDaggerService() { | ||||||
|  |     private var authenticator: AbstractAccountAuthenticator? = null | ||||||
|  | 
 | ||||||
|  |     override fun onCreate() { | ||||||
|  |         super.onCreate() | ||||||
|  |         authenticator = WikiAccountAuthenticator(this) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onBind(intent: Intent): IBinder? = | ||||||
|  |         authenticator?.iBinder | ||||||
|  | } | ||||||
|  | @ -2,15 +2,14 @@ package fr.free.nrw.commons.auth.csrf | ||||||
| 
 | 
 | ||||||
| import androidx.annotation.VisibleForTesting | import androidx.annotation.VisibleForTesting | ||||||
| import fr.free.nrw.commons.auth.SessionManager | import fr.free.nrw.commons.auth.SessionManager | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginClient |  | ||||||
| import fr.free.nrw.commons.auth.login.LoginCallback | 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.LoginFailedException | ||||||
| import fr.free.nrw.commons.auth.login.LoginResult | import fr.free.nrw.commons.auth.login.LoginResult | ||||||
|  | import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||||
| import retrofit2.Call | import retrofit2.Call | ||||||
| import retrofit2.Response | import retrofit2.Response | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import java.io.IOException |  | ||||||
| import java.util.concurrent.Callable | import java.util.concurrent.Callable | ||||||
| import java.util.concurrent.Executors.newSingleThreadExecutor | import java.util.concurrent.Executors.newSingleThreadExecutor | ||||||
| 
 | 
 | ||||||
|  | @ -18,12 +17,11 @@ class CsrfTokenClient( | ||||||
|     private val sessionManager: SessionManager, |     private val sessionManager: SessionManager, | ||||||
|     private val csrfTokenInterface: CsrfTokenInterface, |     private val csrfTokenInterface: CsrfTokenInterface, | ||||||
|     private val loginClient: LoginClient, |     private val loginClient: LoginClient, | ||||||
|     private val logoutClient: LogoutClient |     private val logoutClient: LogoutClient, | ||||||
| ) { | ) { | ||||||
|     private var retries = 0 |     private var retries = 0 | ||||||
|     private var csrfTokenCall: Call<MwQueryResponse?>? = null |     private var csrfTokenCall: Call<MwQueryResponse?>? = null | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     @Throws(Throwable::class) |     @Throws(Throwable::class) | ||||||
|     fun getTokenBlocking(): String { |     fun getTokenBlocking(): String { | ||||||
|         var token = "" |         var token = "" | ||||||
|  | @ -38,11 +36,20 @@ class CsrfTokenClient( | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 // Get CSRFToken response off the main thread. |                 // Get CSRFToken response off the main thread. | ||||||
|                 val response = newSingleThreadExecutor().submit(Callable { |                 val response = | ||||||
|  |                     newSingleThreadExecutor() | ||||||
|  |                         .submit( | ||||||
|  |                             Callable { | ||||||
|                                 csrfTokenInterface.getCsrfTokenCall().execute() |                                 csrfTokenInterface.getCsrfTokenCall().execute() | ||||||
|                 }).get() |                             }, | ||||||
|  |                         ).get() | ||||||
| 
 | 
 | ||||||
|                 if (response.body()?.query()?.csrfToken().isNullOrEmpty()) { |                 if (response | ||||||
|  |                         .body() | ||||||
|  |                         ?.query() | ||||||
|  |                         ?.csrfToken() | ||||||
|  |                         .isNullOrEmpty() | ||||||
|  |                 ) { | ||||||
|                     continue |                     continue | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|  | @ -53,8 +60,7 @@ class CsrfTokenClient( | ||||||
|                 break |                 break | ||||||
|             } catch (e: LoginFailedException) { |             } catch (e: LoginFailedException) { | ||||||
|                 throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) |                 throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||||
|             } |             } catch (t: Throwable) { | ||||||
|             catch (t: Throwable) { |  | ||||||
|                 Timber.w(t) |                 Timber.w(t) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -66,8 +72,13 @@ class CsrfTokenClient( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @VisibleForTesting |     @VisibleForTesting | ||||||
|     fun request(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> = |     fun request( | ||||||
|         requestToken(service, object : Callback { |         service: CsrfTokenInterface, | ||||||
|  |         cb: Callback, | ||||||
|  |     ): Call<MwQueryResponse?> = | ||||||
|  |         requestToken( | ||||||
|  |             service, | ||||||
|  |             object : Callback { | ||||||
|                 override fun success(token: String?) { |                 override fun success(token: String?) { | ||||||
|                     if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { |                     if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { | ||||||
|                         retryWithLogin(cb) { |                         retryWithLogin(cb) { | ||||||
|  | @ -81,30 +92,45 @@ class CsrfTokenClient( | ||||||
|                 override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } |                 override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } | ||||||
| 
 | 
 | ||||||
|                 override fun twoFactorPrompt() = cb.twoFactorPrompt() |                 override fun twoFactorPrompt() = cb.twoFactorPrompt() | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     @VisibleForTesting |     @VisibleForTesting | ||||||
|     fun requestToken(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> { |     fun requestToken( | ||||||
|  |         service: CsrfTokenInterface, | ||||||
|  |         cb: Callback, | ||||||
|  |     ): Call<MwQueryResponse?> { | ||||||
|         val call = service.getCsrfTokenCall() |         val call = service.getCsrfTokenCall() | ||||||
|         call.enqueue(object : retrofit2.Callback<MwQueryResponse?> { |         call.enqueue( | ||||||
|             override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { |             object : retrofit2.Callback<MwQueryResponse?> { | ||||||
|  |                 override fun onResponse( | ||||||
|  |                     call: Call<MwQueryResponse?>, | ||||||
|  |                     response: Response<MwQueryResponse?>, | ||||||
|  |                 ) { | ||||||
|                     if (call.isCanceled) { |                     if (call.isCanceled) { | ||||||
|                         return |                         return | ||||||
|                     } |                     } | ||||||
|                     cb.success(response.body()!!.query()!!.csrfToken()) |                     cb.success(response.body()!!.query()!!.csrfToken()) | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|             override fun onFailure(call: Call<MwQueryResponse?>, t: Throwable) { |                 override fun onFailure( | ||||||
|  |                     call: Call<MwQueryResponse?>, | ||||||
|  |                     t: Throwable, | ||||||
|  |                 ) { | ||||||
|                     if (call.isCanceled) { |                     if (call.isCanceled) { | ||||||
|                         return |                         return | ||||||
|                     } |                     } | ||||||
|                     cb.failure(t) |                     cb.failure(t) | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|         return call |         return call | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private fun retryWithLogin(callback: Callback, caught: () -> Throwable?) { |     private fun retryWithLogin( | ||||||
|  |         callback: Callback, | ||||||
|  |         caught: () -> Throwable?, | ||||||
|  |     ) { | ||||||
|         val userName = sessionManager.userName |         val userName = sessionManager.userName | ||||||
|         val password = sessionManager.password |         val password = sessionManager.password | ||||||
|         if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { |         if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { | ||||||
|  | @ -124,8 +150,11 @@ class CsrfTokenClient( | ||||||
|         username: String, |         username: String, | ||||||
|         password: String, |         password: String, | ||||||
|         callback: Callback, |         callback: Callback, | ||||||
|         retryCallback: () -> Unit |         retryCallback: () -> Unit, | ||||||
|     ) = loginClient.request(username, password, object : LoginCallback { |     ) = loginClient.request( | ||||||
|  |         username, | ||||||
|  |         password, | ||||||
|  |         object : LoginCallback { | ||||||
|             override fun success(loginResult: LoginResult) { |             override fun success(loginResult: LoginResult) { | ||||||
|                 if (loginResult.pass) { |                 if (loginResult.pass) { | ||||||
|                     sessionManager.updateAccount(loginResult) |                     sessionManager.updateAccount(loginResult) | ||||||
|  | @ -135,15 +164,17 @@ class CsrfTokenClient( | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|         override fun twoFactorPrompt(caught: Throwable, token: String?) = |             override fun twoFactorPrompt( | ||||||
|             callback.twoFactorPrompt() |                 caught: Throwable, | ||||||
|  |                 token: String?, | ||||||
|  |             ) = callback.twoFactorPrompt() | ||||||
| 
 | 
 | ||||||
|             // Should not happen here, but call the callback just in case. |             // Should not happen here, but call the callback just in case. | ||||||
|         override fun passwordResetPrompt(token: String?) = |             override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) | ||||||
|             callback.failure(LoginFailedException("Logged in with temporary password.")) |  | ||||||
| 
 | 
 | ||||||
|             override fun error(caught: Throwable) = callback.failure(caught) |             override fun error(caught: Throwable) = callback.failure(caught) | ||||||
|     }) |         }, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     private fun cancel() { |     private fun cancel() { | ||||||
|         loginClient.cancel() |         loginClient.cancel() | ||||||
|  | @ -155,7 +186,9 @@ class CsrfTokenClient( | ||||||
| 
 | 
 | ||||||
|     interface Callback { |     interface Callback { | ||||||
|         fun success(token: String?) |         fun success(token: String?) | ||||||
|  | 
 | ||||||
|         fun failure(caught: Throwable?) |         fun failure(caught: Throwable?) | ||||||
|  | 
 | ||||||
|         fun twoFactorPrompt() |         fun twoFactorPrompt() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -167,5 +200,7 @@ class CsrfTokenClient( | ||||||
|         const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." |         const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." | ||||||
|     } |     } | ||||||
| } | } | ||||||
| class InvalidLoginTokenException(message: String) : Exception(message) |  | ||||||
| 
 | 
 | ||||||
|  | class InvalidLoginTokenException( | ||||||
|  |     message: String, | ||||||
|  | ) : Exception(message) | ||||||
|  |  | ||||||
|  | @ -3,6 +3,10 @@ package fr.free.nrw.commons.auth.csrf | ||||||
| import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage | import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| 
 | 
 | ||||||
| class LogoutClient @Inject constructor(private val store: CommonsCookieStorage) { | class LogoutClient | ||||||
|  |     @Inject | ||||||
|  |     constructor( | ||||||
|  |         private val store: CommonsCookieStorage, | ||||||
|  |     ) { | ||||||
|         fun logout() = store.clear() |         fun logout() = store.clear() | ||||||
|     } |     } | ||||||
|  | @ -2,7 +2,13 @@ package fr.free.nrw.commons.auth.login | ||||||
| 
 | 
 | ||||||
| interface LoginCallback { | interface LoginCallback { | ||||||
|     fun success(loginResult: LoginResult) |     fun success(loginResult: LoginResult) | ||||||
|     fun twoFactorPrompt(caught: Throwable, token: String?) | 
 | ||||||
|  |     fun twoFactorPrompt( | ||||||
|  |         caught: Throwable, | ||||||
|  |         token: String?, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|     fun passwordResetPrompt(token: String?) |     fun passwordResetPrompt(token: String?) | ||||||
|  | 
 | ||||||
|     fun error(caught: Throwable) |     fun error(caught: Throwable) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,9 +4,9 @@ import android.text.TextUtils | ||||||
| import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult | import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult | ||||||
| import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult | import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult | ||||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL | 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.android.schedulers.AndroidSchedulers | ||||||
| import io.reactivex.schedulers.Schedulers | import io.reactivex.schedulers.Schedulers | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse |  | ||||||
| import retrofit2.Call | import retrofit2.Call | ||||||
| import retrofit2.Callback | import retrofit2.Callback | ||||||
| import retrofit2.Response | import retrofit2.Response | ||||||
|  | @ -16,7 +16,9 @@ import java.io.IOException | ||||||
| /** | /** | ||||||
|  * Responsible for making login related requests to the server. |  * Responsible for making login related requests to the server. | ||||||
|  */ |  */ | ||||||
| class LoginClient(private val loginInterface: LoginInterface) { | class LoginClient( | ||||||
|  |     private val loginInterface: LoginInterface, | ||||||
|  | ) { | ||||||
|     private var tokenCall: Call<MwQueryResponse?>? = null |     private var tokenCall: Call<MwQueryResponse?>? = null | ||||||
|     private var loginCall: Call<LoginResponse?>? = null |     private var loginCall: Call<LoginResponse?>? = null | ||||||
| 
 | 
 | ||||||
|  | @ -30,45 +32,75 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
| 
 | 
 | ||||||
|     private fun getLoginToken() = loginInterface.getLoginToken() |     private fun getLoginToken() = loginInterface.getLoginToken() | ||||||
| 
 | 
 | ||||||
|     fun request(userName: String, password: String, cb: LoginCallback) { |     fun request( | ||||||
|  |         userName: String, | ||||||
|  |         password: String, | ||||||
|  |         cb: LoginCallback, | ||||||
|  |     ) { | ||||||
|         cancel() |         cancel() | ||||||
| 
 | 
 | ||||||
|         tokenCall = getLoginToken() |         tokenCall = getLoginToken() | ||||||
|         tokenCall!!.enqueue(object : Callback<MwQueryResponse?> { |         tokenCall!!.enqueue( | ||||||
|             override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { |             object : Callback<MwQueryResponse?> { | ||||||
|  |                 override fun onResponse( | ||||||
|  |                     call: Call<MwQueryResponse?>, | ||||||
|  |                     response: Response<MwQueryResponse?>, | ||||||
|  |                 ) { | ||||||
|                     login( |                     login( | ||||||
|                     userName, password, null, null, response.body()!!.query()!!.loginToken(), |                         userName, | ||||||
|                     userLanguage, cb |                         password, | ||||||
|  |                         null, | ||||||
|  |                         null, | ||||||
|  |                         response.body()!!.query()!!.loginToken(), | ||||||
|  |                         userLanguage, | ||||||
|  |                         cb, | ||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|             override fun onFailure(call: Call<MwQueryResponse?>, caught: Throwable) { |                 override fun onFailure( | ||||||
|  |                     call: Call<MwQueryResponse?>, | ||||||
|  |                     caught: Throwable, | ||||||
|  |                 ) { | ||||||
|                     if (call.isCanceled) { |                     if (call.isCanceled) { | ||||||
|                         return |                         return | ||||||
|                     } |                     } | ||||||
|                     cb.error(caught) |                     cb.error(caught) | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fun login( |  | ||||||
|         userName: String, password: String, retypedPassword: String?, twoFactorCode: String?, |  | ||||||
|         loginToken: String?, userLanguage: String, cb: LoginCallback |  | ||||||
|     ) { |  | ||||||
|         this.userLanguage = userLanguage |  | ||||||
| 
 |  | ||||||
|         loginCall = if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { |  | ||||||
|             loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) |  | ||||||
|         } else { |  | ||||||
|             loginInterface.postLogIn( |  | ||||||
|                 userName, password, retypedPassword, twoFactorCode, loginToken, userLanguage, true |  | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|         loginCall!!.enqueue(object : Callback<LoginResponse?> { |     fun login( | ||||||
|  |         userName: String, | ||||||
|  |         password: String, | ||||||
|  |         retypedPassword: String?, | ||||||
|  |         twoFactorCode: String?, | ||||||
|  |         loginToken: String?, | ||||||
|  |         userLanguage: String, | ||||||
|  |         cb: LoginCallback, | ||||||
|  |     ) { | ||||||
|  |         this.userLanguage = userLanguage | ||||||
|  | 
 | ||||||
|  |         loginCall = | ||||||
|  |             if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { | ||||||
|  |                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||||
|  |             } else { | ||||||
|  |                 loginInterface.postLogIn( | ||||||
|  |                     userName, | ||||||
|  |                     password, | ||||||
|  |                     retypedPassword, | ||||||
|  |                     twoFactorCode, | ||||||
|  |                     loginToken, | ||||||
|  |                     userLanguage, | ||||||
|  |                     true, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |         loginCall!!.enqueue( | ||||||
|  |             object : Callback<LoginResponse?> { | ||||||
|                 override fun onResponse( |                 override fun onResponse( | ||||||
|                     call: Call<LoginResponse?>, |                     call: Call<LoginResponse?>, | ||||||
|                 response: Response<LoginResponse?> |                     response: Response<LoginResponse?>, | ||||||
|                 ) { |                 ) { | ||||||
|                     val loginResult = response.body()?.toLoginResult(password) |                     val loginResult = response.body()?.toLoginResult(password) | ||||||
|                     if (loginResult != null) { |                     if (loginResult != null) { | ||||||
|  | @ -78,15 +110,17 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
|                             getExtendedInfo(loginResult.userName, loginResult, cb) |                             getExtendedInfo(loginResult.userName, loginResult, cb) | ||||||
|                         } else if ("UI" == loginResult.status) { |                         } else if ("UI" == loginResult.status) { | ||||||
|                             when (loginResult) { |                             when (loginResult) { | ||||||
|                             is OAuthResult -> cb.twoFactorPrompt( |                                 is OAuthResult -> | ||||||
|  |                                     cb.twoFactorPrompt( | ||||||
|                                         LoginFailedException(loginResult.message), |                                         LoginFailedException(loginResult.message), | ||||||
|                                 loginToken |                                         loginToken, | ||||||
|                                     ) |                                     ) | ||||||
| 
 | 
 | ||||||
|                                 is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) |                                 is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) | ||||||
| 
 | 
 | ||||||
|                             is LoginResult.Result -> cb.error( |                                 is LoginResult.Result -> | ||||||
|                                 LoginFailedException(loginResult.message) |                                     cb.error( | ||||||
|  |                                         LoginFailedException(loginResult.message), | ||||||
|                                     ) |                                     ) | ||||||
|                             } |                             } | ||||||
|                         } else { |                         } else { | ||||||
|  | @ -97,13 +131,17 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|             override fun onFailure(call: Call<LoginResponse?>, t: Throwable) { |                 override fun onFailure( | ||||||
|  |                     call: Call<LoginResponse?>, | ||||||
|  |                     t: Throwable, | ||||||
|  |                 ) { | ||||||
|                     if (call.isCanceled) { |                     if (call.isCanceled) { | ||||||
|                         return |                         return | ||||||
|                     } |                     } | ||||||
|                     cb.error(t) |                     cb.error(t) | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fun doLogin( |     fun doLogin( | ||||||
|  | @ -111,12 +149,13 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
|         password: String, |         password: String, | ||||||
|         twoFactorCode: String, |         twoFactorCode: String, | ||||||
|         userLanguage: String, |         userLanguage: String, | ||||||
|         loginCallback: LoginCallback |         loginCallback: LoginCallback, | ||||||
|     ) { |     ) { | ||||||
|         getLoginToken().enqueue(object :Callback<MwQueryResponse?>{ |         getLoginToken().enqueue( | ||||||
|  |             object : Callback<MwQueryResponse?> { | ||||||
|                 override fun onResponse( |                 override fun onResponse( | ||||||
|                     call: Call<MwQueryResponse?>, |                     call: Call<MwQueryResponse?>, | ||||||
|                 response: Response<MwQueryResponse?> |                     response: Response<MwQueryResponse?>, | ||||||
|                 ) = if (response.isSuccessful) { |                 ) = if (response.isSuccessful) { | ||||||
|                     val loginToken = response.body()?.query()?.loginToken() |                     val loginToken = response.body()?.query()?.loginToken() | ||||||
|                     loginToken?.let { |                     loginToken?.let { | ||||||
|  | @ -128,24 +167,45 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
|                     loginCallback.error(IOException("Failed to retrieve login token")) |                     loginCallback.error(IOException("Failed to retrieve login token")) | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|             override fun onFailure(call: Call<MwQueryResponse?>, t: Throwable) { |                 override fun onFailure( | ||||||
|  |                     call: Call<MwQueryResponse?>, | ||||||
|  |                     t: Throwable, | ||||||
|  |                 ) { | ||||||
|                     loginCallback.error(t) |                     loginCallback.error(t) | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     @Throws(Throwable::class) |     @Throws(Throwable::class) | ||||||
|     fun loginBlocking(userName: String, password: String, twoFactorCode: String?) { |     fun loginBlocking( | ||||||
|  |         userName: String, | ||||||
|  |         password: String, | ||||||
|  |         twoFactorCode: String?, | ||||||
|  |     ) { | ||||||
|         val tokenResponse = getLoginToken().execute() |         val tokenResponse = getLoginToken().execute() | ||||||
|         if (tokenResponse.body()?.query()?.loginToken().isNullOrEmpty()) { |         if (tokenResponse | ||||||
|  |                 .body() | ||||||
|  |                 ?.query() | ||||||
|  |                 ?.loginToken() | ||||||
|  |                 .isNullOrEmpty() | ||||||
|  |         ) { | ||||||
|             throw IOException("Unexpected response when getting login token.") |             throw IOException("Unexpected response when getting login token.") | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         val loginToken = tokenResponse.body()?.query()?.loginToken() |         val loginToken = tokenResponse.body()?.query()?.loginToken() | ||||||
|         val tempLoginCall = if (twoFactorCode.isNullOrEmpty()) { |         val tempLoginCall = | ||||||
|  |             if (twoFactorCode.isNullOrEmpty()) { | ||||||
|                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) |                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||||
|             } else { |             } else { | ||||||
|                 loginInterface.postLogIn( |                 loginInterface.postLogIn( | ||||||
|                 userName, password, null, twoFactorCode, loginToken, userLanguage, true |                     userName, | ||||||
|  |                     password, | ||||||
|  |                     null, | ||||||
|  |                     twoFactorCode, | ||||||
|  |                     loginToken, | ||||||
|  |                     userLanguage, | ||||||
|  |                     true, | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  | @ -166,13 +226,18 @@ class LoginClient(private val loginInterface: LoginInterface) { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private fun getExtendedInfo(userName: String, loginResult: LoginResult, cb: LoginCallback) = |     private fun getExtendedInfo( | ||||||
|         loginInterface.getUserInfo(userName) |         userName: String, | ||||||
|             .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) |         loginResult: LoginResult, | ||||||
|  |         cb: LoginCallback, | ||||||
|  |     ) = loginInterface | ||||||
|  |         .getUserInfo(userName) | ||||||
|  |         .subscribeOn(Schedulers.io()) | ||||||
|  |         .observeOn(AndroidSchedulers.mainThread()) | ||||||
|         .subscribe({ response: MwQueryResponse? -> |         .subscribe({ response: MwQueryResponse? -> | ||||||
|             loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 |             loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 | ||||||
|             loginResult.groups = |             loginResult.groups = | ||||||
|                     response?.query()?.getUserResponse(userName)?.groups ?: emptySet() |                 response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() | ||||||
|             cb.success(loginResult) |             cb.success(loginResult) | ||||||
|         }, { caught: Throwable -> |         }, { caught: Throwable -> | ||||||
|             Timber.e(caught, "Login succeeded but getting group information failed. ") |             Timber.e(caught, "Login succeeded but getting group information failed. ") | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
| package fr.free.nrw.commons.auth.login | package fr.free.nrw.commons.auth.login | ||||||
| 
 | 
 | ||||||
| class LoginFailedException(message: String?) : Throwable(message) | class LoginFailedException( | ||||||
|  |     message: String?, | ||||||
|  | ) : Throwable(message) | ||||||
|  |  | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| package fr.free.nrw.commons.auth.login | package fr.free.nrw.commons.auth.login | ||||||
| 
 | 
 | ||||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | ||||||
| import io.reactivex.Observable |  | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||||
|  | import io.reactivex.Observable | ||||||
| import retrofit2.Call | import retrofit2.Call | ||||||
| import retrofit2.http.Field | import retrofit2.http.Field | ||||||
| import retrofit2.http.FormUrlEncoded | import retrofit2.http.FormUrlEncoded | ||||||
|  | @ -24,7 +24,7 @@ interface LoginInterface { | ||||||
|         @Field("password") pass: String?, |         @Field("password") pass: String?, | ||||||
|         @Field("logintoken") token: String?, |         @Field("logintoken") token: String?, | ||||||
|         @Field("uselang") userLanguage: String?, |         @Field("uselang") userLanguage: String?, | ||||||
|         @Field("loginreturnurl") url: String? |         @Field("loginreturnurl") url: String?, | ||||||
|     ): Call<LoginResponse?> |     ): Call<LoginResponse?> | ||||||
| 
 | 
 | ||||||
|     @Headers("Cache-Control: no-cache") |     @Headers("Cache-Control: no-cache") | ||||||
|  | @ -37,9 +37,11 @@ interface LoginInterface { | ||||||
|         @Field("OATHToken") twoFactorCode: String?, |         @Field("OATHToken") twoFactorCode: String?, | ||||||
|         @Field("logintoken") token: String?, |         @Field("logintoken") token: String?, | ||||||
|         @Field("uselang") userLanguage: String?, |         @Field("uselang") userLanguage: String?, | ||||||
|         @Field("logincontinue") loginContinue: Boolean |         @Field("logincontinue") loginContinue: Boolean, | ||||||
|     ): Call<LoginResponse?> |     ): Call<LoginResponse?> | ||||||
| 
 | 
 | ||||||
|     @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") |     @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") | ||||||
|     fun getUserInfo(@Query("ususers") userName: String): Observable<MwQueryResponse?> |     fun getUserInfo( | ||||||
|  |         @Query("ususers") userName: String, | ||||||
|  |     ): Observable<MwQueryResponse?> | ||||||
| } | } | ||||||
|  | @ -13,9 +13,7 @@ class LoginResponse { | ||||||
|     @SerializedName("clientlogin") |     @SerializedName("clientlogin") | ||||||
|     private val clientLogin: ClientLogin? = null |     private val clientLogin: ClientLogin? = null | ||||||
| 
 | 
 | ||||||
|     fun toLoginResult(password: String): LoginResult? { |     fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password) | ||||||
|         return clientLogin?.toLoginResult(password) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| internal class ClientLogin { | internal class ClientLogin { | ||||||
|  |  | ||||||
|  | @ -4,7 +4,7 @@ sealed class LoginResult( | ||||||
|     val status: String, |     val status: String, | ||||||
|     val userName: String?, |     val userName: String?, | ||||||
|     val password: String?, |     val password: String?, | ||||||
|     val message: String? |     val message: String?, | ||||||
| ) { | ) { | ||||||
|     var userId = 0 |     var userId = 0 | ||||||
|     var groups = emptySet<String>() |     var groups = emptySet<String>() | ||||||
|  | @ -14,20 +14,20 @@ sealed class LoginResult( | ||||||
|         status: String, |         status: String, | ||||||
|         userName: String?, |         userName: String?, | ||||||
|         password: String?, |         password: String?, | ||||||
|         message: String? |         message: String?, | ||||||
|     ) : LoginResult(status, userName, password, message) |     ) : LoginResult(status, userName, password, message) | ||||||
| 
 | 
 | ||||||
|     class OAuthResult( |     class OAuthResult( | ||||||
|         status: String, |         status: String, | ||||||
|         userName: String?, |         userName: String?, | ||||||
|         password: String?, |         password: String?, | ||||||
|         message: String? |         message: String?, | ||||||
|     ) : LoginResult(status, userName, password, message) |     ) : LoginResult(status, userName, password, message) | ||||||
| 
 | 
 | ||||||
|     class ResetPasswordResult( |     class ResetPasswordResult( | ||||||
|         status: String, |         status: String, | ||||||
|         userName: String?, |         userName: String?, | ||||||
|         password: String?, |         password: String?, | ||||||
|         message: String? |         message: String?, | ||||||
|     ) : LoginResult(status, userName, password, message) |     ) : LoginResult(status, userName, password, message) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,18 +2,17 @@ package fr.free.nrw.commons.bookmarks; | ||||||
| 
 | 
 | ||||||
| import android.content.Context; | import android.content.Context; | ||||||
| import android.os.Bundle; | import android.os.Bundle; | ||||||
| import android.util.Log; |  | ||||||
| import android.view.LayoutInflater; | import android.view.LayoutInflater; | ||||||
| import android.view.View; | import android.view.View; | ||||||
| import android.view.ViewGroup; | import android.view.ViewGroup; | ||||||
| import android.widget.AdapterView; | import android.widget.AdapterView; | ||||||
| import android.widget.FrameLayout; |  | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | import androidx.annotation.Nullable; | ||||||
| import androidx.fragment.app.Fragment; | import androidx.fragment.app.Fragment; | ||||||
| import androidx.fragment.app.FragmentManager; | import androidx.fragment.app.FragmentManager; | ||||||
| import fr.free.nrw.commons.Media; | import fr.free.nrw.commons.Media; | ||||||
| import fr.free.nrw.commons.R; | 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.items.BookmarkItemsFragment; | ||||||
| import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; | import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; | ||||||
| import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; | import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; | ||||||
|  | @ -26,6 +25,7 @@ import fr.free.nrw.commons.media.MediaDetailPagerFragment; | ||||||
| import fr.free.nrw.commons.navtab.NavTab; | import fr.free.nrw.commons.navtab.NavTab; | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.Iterator; | import java.util.Iterator; | ||||||
|  | import timber.log.Timber; | ||||||
| 
 | 
 | ||||||
| public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements | public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements | ||||||
|     FragmentManager.OnBackStackChangedListener, |     FragmentManager.OnBackStackChangedListener, | ||||||
|  | @ -48,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple | ||||||
|         String title = bundle.getString("categoryName"); |         String title = bundle.getString("categoryName"); | ||||||
|         int order = bundle.getInt("order"); |         int order = bundle.getInt("order"); | ||||||
|         final int orderItem = bundle.getInt("orderItem"); |         final int orderItem = bundle.getInt("orderItem"); | ||||||
|         if (order == 0) { | 
 | ||||||
|             listFragment = new BookmarkPicturesFragment(); |         switch (order){ | ||||||
|         } else { |             case 0: listFragment = new BookmarkPicturesFragment(); | ||||||
|             listFragment = new BookmarkLocationsFragment(); |             break; | ||||||
|  | 
 | ||||||
|  |             case 1: listFragment = new BookmarkLocationsFragment(); | ||||||
|  |             break; | ||||||
|  | 
 | ||||||
|  |             case 3: listFragment = new BookmarkCategoriesFragment(); | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|             if(orderItem == 2) { |             if(orderItem == 2) { | ||||||
|                 listFragment = new BookmarkItemsFragment(); |                 listFragment = new BookmarkItemsFragment(); | ||||||
|             } |             } | ||||||
|         } | 
 | ||||||
|         Bundle featuredArguments = new Bundle(); |         Bundle featuredArguments = new Bundle(); | ||||||
|         featuredArguments.putString("categoryName", title); |         featuredArguments.putString("categoryName", title); | ||||||
|         listFragment.setArguments(featuredArguments); |         listFragment.setArguments(featuredArguments); | ||||||
|  | @ -129,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple | ||||||
| 
 | 
 | ||||||
|     @Override |     @Override | ||||||
|     public void onMediaClicked(int position) { |     public void onMediaClicked(int position) { | ||||||
|         Log.d("deneme8", "on media clicked"); |         Timber.d("on media clicked"); | ||||||
|     /*container.setVisibility(View.VISIBLE); |     /*container.setVisibility(View.VISIBLE); | ||||||
|     ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); |     ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); | ||||||
|     mediaDetails = new MediaDetailPagerFragment(false, true, position); |     mediaDetails = new MediaDetailPagerFragment(false, true, position); | ||||||
|  | @ -237,7 +244,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple | ||||||
| 
 | 
 | ||||||
|     @Override |     @Override | ||||||
|     public void onItemClick(AdapterView<?> parent, View view, int position, long id) { |     public void onItemClick(AdapterView<?> parent, View view, int position, long id) { | ||||||
|         Log.d("deneme8", "on media clicked"); |         Timber.d("on media clicked"); | ||||||
|         binding.exploreContainer.setVisibility(View.VISIBLE); |         binding.exploreContainer.setVisibility(View.VISIBLE); | ||||||
|         ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE); |         ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE); | ||||||
|         mediaDetails = MediaDetailPagerFragment.newInstance(false, true); |         mediaDetails = MediaDetailPagerFragment.newInstance(false, true); | ||||||
|  |  | ||||||
|  | @ -1,32 +0,0 @@ | ||||||
| package fr.free.nrw.commons.bookmarks; |  | ||||||
| 
 |  | ||||||
| import androidx.fragment.app.Fragment; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Data class for handling a bookmark fragment and it title |  | ||||||
|  */ |  | ||||||
| public class BookmarkPages { |  | ||||||
|     private Fragment page; |  | ||||||
|     private String title; |  | ||||||
| 
 |  | ||||||
|     BookmarkPages(Fragment fragment, String title) { |  | ||||||
|         this.title = title; |  | ||||||
|         this.page = fragment; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Return the fragment |  | ||||||
|      * @return fragment object |  | ||||||
|      */ |  | ||||||
|     public Fragment getPage() { |  | ||||||
|         return page; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Return the fragment title |  | ||||||
|      * @return title |  | ||||||
|      */ |  | ||||||
|     public String getTitle() { |  | ||||||
|         return title; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | package fr.free.nrw.commons.bookmarks | ||||||
|  | 
 | ||||||
|  | import androidx.fragment.app.Fragment | ||||||
|  | 
 | ||||||
|  | data class BookmarkPages ( | ||||||
|  |     val page: Fragment? = null, | ||||||
|  |     val title: String? = null | ||||||
|  | ) | ||||||
|  | @ -49,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter { | ||||||
|                 new BookmarkListRootFragment(locationBundle, this), |                 new BookmarkListRootFragment(locationBundle, this), | ||||||
|                 context.getString(R.string.title_page_bookmarks_items))); |                 context.getString(R.string.title_page_bookmarks_items))); | ||||||
|         } |         } | ||||||
|  |         final Bundle categoriesBundle = new Bundle(); | ||||||
|  |         categoriesBundle.putString("categoryName", | ||||||
|  |             context.getString(R.string.title_page_bookmarks_categories)); | ||||||
|  |         categoriesBundle.putInt("order", 3); | ||||||
|  |         pages.add(new BookmarkPages( | ||||||
|  |             new BookmarkListRootFragment(categoriesBundle, this), | ||||||
|  |             context.getString(R.string.title_page_bookmarks_categories))); | ||||||
|         notifyDataSetChanged(); |         notifyDataSetChanged(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,52 @@ | ||||||
|  | package fr.free.nrw.commons.bookmarks.category | ||||||
|  | 
 | ||||||
|  | import androidx.room.Dao | ||||||
|  | import androidx.room.Delete | ||||||
|  | import androidx.room.Insert | ||||||
|  | import androidx.room.OnConflictStrategy | ||||||
|  | import androidx.room.Query | ||||||
|  | import kotlinx.coroutines.flow.Flow | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Bookmark categories dao | ||||||
|  |  * | ||||||
|  |  * @constructor Create empty Bookmark categories dao | ||||||
|  |  */ | ||||||
|  | @Dao | ||||||
|  | interface BookmarkCategoriesDao { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Insert or Delete category bookmark into DB | ||||||
|  |      * | ||||||
|  |      * @param bookmarksCategoryModal | ||||||
|  |      */ | ||||||
|  |     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||||
|  |     suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Delete category bookmark from DB | ||||||
|  |      * | ||||||
|  |      * @param bookmarksCategoryModal | ||||||
|  |      */ | ||||||
|  |     @Delete | ||||||
|  |     suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal) | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Checks if given category exist in DB | ||||||
|  |      * | ||||||
|  |      * @param categoryName | ||||||
|  |      * @return | ||||||
|  |      */ | ||||||
|  |     @Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)") | ||||||
|  |     suspend fun doesExist(categoryName: String): Boolean | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Get all categories | ||||||
|  |      * | ||||||
|  |      * @return | ||||||
|  |      */ | ||||||
|  |     @Query("SELECT * FROM bookmarks_categories") | ||||||
|  |     fun getAllCategories(): Flow<List<BookmarksCategoryModal>> | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,143 @@ | ||||||
|  | package fr.free.nrw.commons.bookmarks.category | ||||||
|  | 
 | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.view.LayoutInflater | ||||||
|  | import android.view.View | ||||||
|  | import android.view.ViewGroup | ||||||
|  | import androidx.compose.foundation.Image | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.material3.ListItem | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Surface | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.material3.darkColorScheme | ||||||
|  | import androidx.compose.material3.lightColorScheme | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.platform.ComposeView | ||||||
|  | import androidx.compose.ui.platform.ViewCompositionStrategy | ||||||
|  | import androidx.compose.ui.res.colorResource | ||||||
|  | import androidx.compose.ui.res.painterResource | ||||||
|  | import androidx.compose.ui.res.stringResource | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.tooling.preview.Preview | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||||||
|  | import dagger.android.support.DaggerFragment | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.category.CategoryDetailsActivity | ||||||
|  | import javax.inject.Inject | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Tab fragment to show list of bookmarked Categories | ||||||
|  |  */ | ||||||
|  | class BookmarkCategoriesFragment : DaggerFragment() { | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao | ||||||
|  | 
 | ||||||
|  |     override fun onCreateView( | ||||||
|  |         inflater: LayoutInflater, container: ViewGroup?, | ||||||
|  |         savedInstanceState: Bundle? | ||||||
|  |     ): View { | ||||||
|  |         return ComposeView(requireContext()).apply { | ||||||
|  |             setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | ||||||
|  |             setContent { | ||||||
|  |                 MaterialTheme( | ||||||
|  |                     colorScheme = if (isSystemInDarkTheme()) darkColorScheme( | ||||||
|  |                         primary = colorResource(R.color.primaryDarkColor), | ||||||
|  |                         surface = colorResource(R.color.main_background_dark), | ||||||
|  |                         background = colorResource(R.color.main_background_dark) | ||||||
|  |                     ) else lightColorScheme( | ||||||
|  |                         primary = colorResource(R.color.primaryColor), | ||||||
|  |                         surface = colorResource(R.color.main_background_light), | ||||||
|  |                         background = colorResource(R.color.main_background_light) | ||||||
|  |                     ) | ||||||
|  |                 ) { | ||||||
|  |                     val listOfBookmarks by bookmarkCategoriesDao.getAllCategories() | ||||||
|  |                         .collectAsStateWithLifecycle(initialValue = emptyList()) | ||||||
|  |                     Surface(modifier = Modifier.fillMaxSize()) { | ||||||
|  |                         Box(contentAlignment = Alignment.Center) { | ||||||
|  |                             if (listOfBookmarks.isEmpty()) { | ||||||
|  |                                 Text( | ||||||
|  |                                     text = stringResource(R.string.bookmark_empty), | ||||||
|  |                                     style = MaterialTheme.typography.bodyMedium, | ||||||
|  |                                     color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF) | ||||||
|  |                                     else Color( | ||||||
|  |                                         0x8A000000 | ||||||
|  |                                     ) | ||||||
|  |                                 ) | ||||||
|  |                             } else { | ||||||
|  |                                 LazyColumn(modifier = Modifier.fillMaxSize()) { | ||||||
|  |                                     items(items = listOfBookmarks) { bookmarkItem -> | ||||||
|  |                                         CategoryItem( | ||||||
|  |                                             categoryName = bookmarkItem.categoryName, | ||||||
|  |                                             onClick = { | ||||||
|  |                                                 val categoryDetailsIntent = Intent( | ||||||
|  |                                                     requireContext(), | ||||||
|  |                                                     CategoryDetailsActivity::class.java | ||||||
|  |                                                 ).putExtra("categoryName", it) | ||||||
|  |                                                 startActivity(categoryDetailsIntent) | ||||||
|  |                                             } | ||||||
|  |                                         ) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     @Composable | ||||||
|  |     fun CategoryItem( | ||||||
|  |         modifier: Modifier = Modifier, | ||||||
|  |         onClick: (String) -> Unit, | ||||||
|  |         categoryName: String | ||||||
|  |     ) { | ||||||
|  |         Row(modifier = modifier.clickable { | ||||||
|  |             onClick(categoryName) | ||||||
|  |         }) { | ||||||
|  |             ListItem( | ||||||
|  |                 leadingContent = { | ||||||
|  |                     Image( | ||||||
|  |                         modifier = Modifier.size(48.dp), | ||||||
|  |                         painter = painterResource(R.drawable.commons), | ||||||
|  |                         contentDescription = null | ||||||
|  |                     ) | ||||||
|  |                 }, | ||||||
|  |                 headlineContent = { | ||||||
|  |                     Text( | ||||||
|  |                         text = categoryName, | ||||||
|  |                         maxLines = 2, | ||||||
|  |                         color = if (isSystemInDarkTheme()) Color.White else Color.Black, | ||||||
|  |                         style = MaterialTheme.typography.bodyMedium, | ||||||
|  |                         fontWeight = FontWeight.SemiBold | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Preview | ||||||
|  |     @Composable | ||||||
|  |     private fun CategoryItemPreview() { | ||||||
|  |         CategoryItem( | ||||||
|  |             onClick = {}, | ||||||
|  |             categoryName = "Test Category" | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | package fr.free.nrw.commons.bookmarks.category | ||||||
|  | 
 | ||||||
|  | import androidx.room.Entity | ||||||
|  | import androidx.room.PrimaryKey | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Data class representing bookmarked category in DB | ||||||
|  |  * | ||||||
|  |  * @property categoryName | ||||||
|  |  * @constructor Create empty Bookmarks category modal | ||||||
|  |  */ | ||||||
|  | @Entity(tableName = "bookmarks_categories") | ||||||
|  | data class BookmarksCategoryModal( | ||||||
|  |     @PrimaryKey val categoryName: String | ||||||
|  | ) | ||||||
|  | @ -15,25 +15,34 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem | ||||||
| /** | /** | ||||||
|  * Helps to inflate Wikidata Items into Items tab |  * Helps to inflate Wikidata Items into Items tab | ||||||
|  */ |  */ | ||||||
| class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) : | class BookmarkItemsAdapter( | ||||||
|     RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() { |     val list: List<DepictedItem>, | ||||||
| 
 |     val context: Context, | ||||||
|     class BookmarkItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { | ) : RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() { | ||||||
| 
 |     class BookmarkItemViewHolder( | ||||||
|  |         itemView: View, | ||||||
|  |     ) : RecyclerView.ViewHolder(itemView) { | ||||||
|         var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) |         var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) | ||||||
|         var description: TextView = itemView.findViewById(R.id.description) |         var description: TextView = itemView.findViewById(R.id.description) | ||||||
|         var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) |         var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) | ||||||
|         var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item) |         var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkItemViewHolder { |     override fun onCreateViewHolder( | ||||||
|         val v: View = LayoutInflater.from(context) |         parent: ViewGroup, | ||||||
|  |         viewType: Int, | ||||||
|  |     ): BookmarkItemViewHolder { | ||||||
|  |         val v: View = | ||||||
|  |             LayoutInflater | ||||||
|  |                 .from(context) | ||||||
|                 .inflate(R.layout.item_depictions, parent, false) |                 .inflate(R.layout.item_depictions, parent, false) | ||||||
|         return BookmarkItemViewHolder(v) |         return BookmarkItemViewHolder(v) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     override fun onBindViewHolder(holder: BookmarkItemViewHolder, position: Int) { |     override fun onBindViewHolder( | ||||||
| 
 |         holder: BookmarkItemViewHolder, | ||||||
|  |         position: Int, | ||||||
|  |     ) { | ||||||
|         val depictedItem = list[position] |         val depictedItem = list[position] | ||||||
|         holder.depictsLabel.text = depictedItem.name |         holder.depictsLabel.text = depictedItem.name | ||||||
|         holder.description.text = depictedItem.description |         holder.description.text = depictedItem.description | ||||||
|  | @ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     override fun getItemCount(): Int { |     override fun getItemCount(): Int = list.size | ||||||
|         return list.size |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| package fr.free.nrw.commons.bookmarks.items; | package fr.free.nrw.commons.bookmarks.items; | ||||||
| 
 | 
 | ||||||
|  | import android.annotation.SuppressLint; | ||||||
| import android.content.ContentProviderClient; | import android.content.ContentProviderClient; | ||||||
| import android.content.ContentValues; | import android.content.ContentValues; | ||||||
| import android.database.Cursor; | import android.database.Cursor; | ||||||
|  | @ -134,6 +135,7 @@ public class BookmarkItemsDao { | ||||||
|      * @param cursor : Object for storing database data |      * @param cursor : Object for storing database data | ||||||
|      * @return DepictedItem |      * @return DepictedItem | ||||||
|      */ |      */ | ||||||
|  |     @SuppressLint("Range") | ||||||
|     DepictedItem fromCursor(final Cursor cursor) { |     DepictedItem fromCursor(final Cursor cursor) { | ||||||
|         final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); |         final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); | ||||||
|         final String description |         final String description | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| package fr.free.nrw.commons.bookmarks.locations; | package fr.free.nrw.commons.bookmarks.locations; | ||||||
| 
 | 
 | ||||||
|  | import android.annotation.SuppressLint; | ||||||
| import android.content.ContentProviderClient; | import android.content.ContentProviderClient; | ||||||
| import android.content.ContentValues; | import android.content.ContentValues; | ||||||
| import android.database.Cursor; | import android.database.Cursor; | ||||||
|  | @ -146,6 +147,7 @@ public class BookmarkLocationsDao { | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     @SuppressLint("Range") | ||||||
|     @NonNull |     @NonNull | ||||||
|     Place fromCursor(final Cursor cursor) { |     Place fromCursor(final Cursor cursor) { | ||||||
|         final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)), |         final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)), | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import android.view.ViewGroup; | ||||||
| import androidx.activity.result.ActivityResultCallback; | import androidx.activity.result.ActivityResultCallback; | ||||||
| import androidx.activity.result.ActivityResultLauncher; | import androidx.activity.result.ActivityResultLauncher; | ||||||
| import androidx.activity.result.contract.ActivityResultContracts; | import androidx.activity.result.contract.ActivityResultContracts; | ||||||
|  | import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; | ||||||
| import androidx.annotation.NonNull; | import androidx.annotation.NonNull; | ||||||
| import androidx.annotation.Nullable; | import androidx.annotation.Nullable; | ||||||
| import androidx.recyclerview.widget.LinearLayoutManager; | import androidx.recyclerview.widget.LinearLayoutManager; | ||||||
|  | @ -33,6 +34,23 @@ public class BookmarkLocationsFragment extends DaggerFragment { | ||||||
|     @Inject BookmarkLocationsDao bookmarkLocationDao; |     @Inject BookmarkLocationsDao bookmarkLocationDao; | ||||||
|     @Inject CommonPlaceClickActions commonPlaceClickActions; |     @Inject CommonPlaceClickActions commonPlaceClickActions; | ||||||
|     private PlaceAdapter adapter; |     private PlaceAdapter adapter; | ||||||
|  | 
 | ||||||
|  |     private final ActivityResultLauncher<Intent> cameraPickLauncherForResult = | ||||||
|  |         registerForActivityResult(new StartActivityForResult(), | ||||||
|  |         result -> { | ||||||
|  |             contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { | ||||||
|  |                 contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks); | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |       private final ActivityResultLauncher<Intent> galleryPickLauncherForResult = | ||||||
|  |           registerForActivityResult(new StartActivityForResult(), | ||||||
|  |         result -> { | ||||||
|  |               contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { | ||||||
|  |                 contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks); | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|     private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() { |     private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() { | ||||||
|         @Override |         @Override | ||||||
|         public void onActivityResult(Map<String, Boolean> result) { |         public void onActivityResult(Map<String, Boolean> result) { | ||||||
|  | @ -45,7 +63,7 @@ public class BookmarkLocationsFragment extends DaggerFragment { | ||||||
|                 contributionController.locationPermissionCallback.onLocationPermissionGranted(); |                 contributionController.locationPermissionCallback.onLocationPermissionGranted(); | ||||||
|             } else { |             } else { | ||||||
|                 if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { |                 if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { | ||||||
|                     contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher); |                     contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); | ||||||
|                 } else { |                 } else { | ||||||
|                     contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied)); |                     contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied)); | ||||||
|                 } |                 } | ||||||
|  | @ -83,7 +101,9 @@ public class BookmarkLocationsFragment extends DaggerFragment { | ||||||
|                 return Unit.INSTANCE; |                 return Unit.INSTANCE; | ||||||
|             }, |             }, | ||||||
|             commonPlaceClickActions, |             commonPlaceClickActions, | ||||||
|             inAppCameraLocationPermissionLauncher |             inAppCameraLocationPermissionLauncher, | ||||||
|  |             galleryPickLauncherForResult, | ||||||
|  |             cameraPickLauncherForResult | ||||||
|         ); |         ); | ||||||
|         binding.listView.setAdapter(adapter); |         binding.listView.setAdapter(adapter); | ||||||
|     } |     } | ||||||
|  | @ -109,11 +129,6 @@ public class BookmarkLocationsFragment extends DaggerFragment { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Override |  | ||||||
|     public void onActivityResult(int requestCode, int resultCode, Intent data) { |  | ||||||
|         contributionController.handleActivityResult(getActivity(), requestCode, resultCode, data); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |     @Override | ||||||
|     public void onDestroy() { |     public void onDestroy() { | ||||||
|         super.onDestroy(); |         super.onDestroy(); | ||||||
|  |  | ||||||
|  | @ -2,25 +2,25 @@ package fr.free.nrw.commons.bookmarks.models | ||||||
| 
 | 
 | ||||||
| import android.net.Uri | import android.net.Uri | ||||||
| 
 | 
 | ||||||
| class Bookmark(mediaName: String?, mediaCreator: String?, | class Bookmark( | ||||||
|  |     mediaName: String?, | ||||||
|  |     mediaCreator: String?, | ||||||
|     /** |     /** | ||||||
|                 * Modifies the content URI - marking this bookmark as already saved in the database |      * Gets or Sets the content URI - marking this bookmark as already saved in the database | ||||||
|  |      * @return content URI | ||||||
|      * @param contentUri the content URI |      * @param contentUri the content URI | ||||||
|      */ |      */ | ||||||
|                var contentUri: Uri?) { |     var contentUri: Uri?, | ||||||
|     /** | ) { | ||||||
|      * Gets the content URI for this bookmark |  | ||||||
|      * @return content URI |  | ||||||
|      */ |  | ||||||
|     /** |     /** | ||||||
|      * Gets the media name |      * Gets the media name | ||||||
|      * @return the media name |      * @return the media name | ||||||
|      */ |      */ | ||||||
|     val mediaName: String = mediaName ?: "" |     val mediaName: String = mediaName ?: "" | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Gets media creator |      * Gets media creator | ||||||
|      * @return creator name |      * @return creator name | ||||||
|      */ |      */ | ||||||
|     val mediaCreator: String = mediaCreator ?: "" |     val mediaCreator: String = mediaCreator ?: "" | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| package fr.free.nrw.commons.bookmarks.pictures; | package fr.free.nrw.commons.bookmarks.pictures; | ||||||
| 
 | 
 | ||||||
|  | import android.annotation.SuppressLint; | ||||||
| import android.content.ContentProviderClient; | import android.content.ContentProviderClient; | ||||||
| import android.content.ContentValues; | import android.content.ContentValues; | ||||||
| import android.database.Cursor; | import android.database.Cursor; | ||||||
|  | @ -150,6 +151,7 @@ public class BookmarkPicturesDao { | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     @SuppressLint("Range") | ||||||
|     @NonNull |     @NonNull | ||||||
|     Bookmark fromCursor(Cursor cursor) { |     Bookmark fromCursor(Cursor cursor) { | ||||||
|         String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME)); |         String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME)); | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName | ||||||
| class CampaignConfig { | class CampaignConfig { | ||||||
|     @SerializedName("showOnlyLiveCampaigns") |     @SerializedName("showOnlyLiveCampaigns") | ||||||
|     private val showOnlyLiveCampaigns = false |     private val showOnlyLiveCampaigns = false | ||||||
|  | 
 | ||||||
|     @SerializedName("sortBy") |     @SerializedName("sortBy") | ||||||
|     private val sortBy: String? = null |     private val sortBy: String? = null | ||||||
| } | } | ||||||
|  | @ -9,7 +9,7 @@ import fr.free.nrw.commons.campaigns.models.Campaign | ||||||
| class CampaignResponseDTO { | class CampaignResponseDTO { | ||||||
|     @SerializedName("config") |     @SerializedName("config") | ||||||
|     val campaignConfig: CampaignConfig? = null |     val campaignConfig: CampaignConfig? = null | ||||||
|  | 
 | ||||||
|     @SerializedName("campaigns") |     @SerializedName("campaigns") | ||||||
|     val campaigns: List<Campaign>? = null |     val campaigns: List<Campaign>? = null | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | @ -1,118 +0,0 @@ | ||||||
| package fr.free.nrw.commons.campaigns; |  | ||||||
| 
 |  | ||||||
| import android.content.Context; |  | ||||||
| import android.net.Uri; |  | ||||||
| import android.util.AttributeSet; |  | ||||||
| import android.view.LayoutInflater; |  | ||||||
| import android.view.View; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.campaigns.models.Campaign; |  | ||||||
| import fr.free.nrw.commons.databinding.LayoutCampaginBinding; |  | ||||||
| import fr.free.nrw.commons.theme.BaseActivity; |  | ||||||
| import fr.free.nrw.commons.utils.DateUtil; |  | ||||||
| 
 |  | ||||||
| import java.text.ParseException; |  | ||||||
| import java.util.Date; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.Utils; |  | ||||||
| import fr.free.nrw.commons.contributions.MainActivity; |  | ||||||
| import fr.free.nrw.commons.utils.CommonsDateUtil; |  | ||||||
| import fr.free.nrw.commons.utils.SwipableCardView; |  | ||||||
| import fr.free.nrw.commons.utils.ViewUtil; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * A view which represents a single campaign |  | ||||||
|  */ |  | ||||||
| public class CampaignView extends SwipableCardView { |  | ||||||
|     Campaign campaign; |  | ||||||
|     private LayoutCampaginBinding binding; |  | ||||||
|     private ViewHolder viewHolder; |  | ||||||
| 
 |  | ||||||
|     public static final String CAMPAIGNS_DEFAULT_PREFERENCE = "displayCampaignsCardView"; |  | ||||||
|     public static final String WLM_CARD_PREFERENCE = "displayWLMCardView"; |  | ||||||
| 
 |  | ||||||
|     private String campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE; |  | ||||||
| 
 |  | ||||||
|     public CampaignView(@NonNull Context context) { |  | ||||||
|         super(context); |  | ||||||
|         init(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs) { |  | ||||||
|         super(context, attrs); |  | ||||||
|         init(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { |  | ||||||
|         super(context, attrs, defStyleAttr); |  | ||||||
|         init(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void setCampaign(final Campaign campaign) { |  | ||||||
|         this.campaign = campaign; |  | ||||||
|         if (campaign != null) { |  | ||||||
|             if (campaign.isWLMCampaign()) { |  | ||||||
|                 campaignPreference = WLM_CARD_PREFERENCE; |  | ||||||
|             } |  | ||||||
|             setVisibility(View.VISIBLE); |  | ||||||
|             viewHolder.init(); |  | ||||||
|         } else { |  | ||||||
|             this.setVisibility(View.GONE); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override public boolean onSwipe(final View view) { |  | ||||||
|         view.setVisibility(View.GONE); |  | ||||||
|         ((BaseActivity) getContext()).defaultKvStore |  | ||||||
|             .putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false); |  | ||||||
|         ViewUtil.showLongToast(getContext(), |  | ||||||
|             getResources().getString(R.string.nearby_campaign_dismiss_message)); |  | ||||||
|         return true; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void init() { |  | ||||||
|         binding = LayoutCampaginBinding.inflate(LayoutInflater.from(getContext()), this, true); |  | ||||||
|         viewHolder = new ViewHolder(); |  | ||||||
|         setOnClickListener(view -> { |  | ||||||
|             if (campaign != null) { |  | ||||||
|                 if (campaign.isWLMCampaign()) { |  | ||||||
|                     ((MainActivity)(getContext())).showNearby(); |  | ||||||
|                 } else { |  | ||||||
|                     Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink())); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public class ViewHolder { |  | ||||||
|         public void init() { |  | ||||||
|             if (campaign != null) { |  | ||||||
|                 binding.ivCampaign.setImageDrawable( |  | ||||||
|                     getResources().getDrawable(R.drawable.ic_campaign)); |  | ||||||
| 
 |  | ||||||
|                 binding.tvTitle.setText(campaign.getTitle()); |  | ||||||
|                 binding.tvDescription.setText(campaign.getDescription()); |  | ||||||
|                 try { |  | ||||||
|                     if (campaign.isWLMCampaign()) { |  | ||||||
|                         binding.tvDates.setText( |  | ||||||
|                             String.format("%1s - %2s", campaign.getStartDate(), |  | ||||||
|                                 campaign.getEndDate())); |  | ||||||
|                     } else { |  | ||||||
|                         final Date startDate = CommonsDateUtil.getIso8601DateFormatShort() |  | ||||||
|                             .parse(campaign.getStartDate()); |  | ||||||
|                         final Date endDate = CommonsDateUtil.getIso8601DateFormatShort() |  | ||||||
|                             .parse(campaign.getEndDate()); |  | ||||||
|                         binding.tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate), |  | ||||||
|                             DateUtil.getExtraShortDateString(endDate))); |  | ||||||
|                     } |  | ||||||
|                 } catch (final ParseException e) { |  | ||||||
|                     e.printStackTrace(); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										121
									
								
								app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | ||||||
|  | package fr.free.nrw.commons.campaigns | ||||||
|  | 
 | ||||||
|  | import android.content.Context | ||||||
|  | import android.net.Uri | ||||||
|  | import android.util.AttributeSet | ||||||
|  | import android.view.LayoutInflater | ||||||
|  | import android.view.View | ||||||
|  | import androidx.core.content.ContextCompat | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.Utils | ||||||
|  | import fr.free.nrw.commons.campaigns.models.Campaign | ||||||
|  | import fr.free.nrw.commons.contributions.MainActivity | ||||||
|  | import fr.free.nrw.commons.databinding.LayoutCampaginBinding | ||||||
|  | import fr.free.nrw.commons.theme.BaseActivity | ||||||
|  | import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort | ||||||
|  | import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString | ||||||
|  | import fr.free.nrw.commons.utils.SwipableCardView | ||||||
|  | import fr.free.nrw.commons.utils.ViewUtil.showLongToast | ||||||
|  | import timber.log.Timber | ||||||
|  | import java.text.ParseException | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * A view which represents a single campaign | ||||||
|  |  */ | ||||||
|  | class CampaignView : SwipableCardView { | ||||||
|  |     private var campaign: Campaign? = null | ||||||
|  |     private var binding: LayoutCampaginBinding? = null | ||||||
|  |     private var viewHolder: ViewHolder? = null | ||||||
|  |     private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE | ||||||
|  | 
 | ||||||
|  |     constructor(context: Context) : super(context) { | ||||||
|  |         init() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { | ||||||
|  |         init() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( | ||||||
|  |         context, attrs, defStyleAttr) { | ||||||
|  |         init() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun setCampaign(campaign: Campaign?) { | ||||||
|  |         this.campaign = campaign | ||||||
|  |         if (campaign != null) { | ||||||
|  |             if (campaign.isWLMCampaign) { | ||||||
|  |                 campaignPreference = WLM_CARD_PREFERENCE | ||||||
|  |             } | ||||||
|  |             visibility = VISIBLE | ||||||
|  |             viewHolder!!.init() | ||||||
|  |         } else { | ||||||
|  |             visibility = GONE | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onSwipe(view: View): Boolean { | ||||||
|  |         view.visibility = GONE | ||||||
|  |         (context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false) | ||||||
|  |         showLongToast( | ||||||
|  |             context, | ||||||
|  |             resources.getString(R.string.nearby_campaign_dismiss_message) | ||||||
|  |         ) | ||||||
|  |         return true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun init() { | ||||||
|  |         binding = LayoutCampaginBinding.inflate( | ||||||
|  |             LayoutInflater.from(context), this, true | ||||||
|  |         ) | ||||||
|  |         viewHolder = ViewHolder() | ||||||
|  |         setOnClickListener { | ||||||
|  |             campaign?.let { | ||||||
|  |                 if (it.isWLMCampaign) { | ||||||
|  |                     ((context) as MainActivity).showNearby() | ||||||
|  |                 } else { | ||||||
|  |                     Utils.handleWebUrl(context, Uri.parse(it.link)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     inner class ViewHolder { | ||||||
|  |         fun init() { | ||||||
|  |             if (campaign != null) { | ||||||
|  |                 binding!!.ivCampaign.setImageDrawable( | ||||||
|  |                     ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign) | ||||||
|  |                 ) | ||||||
|  |                 binding!!.tvTitle.text = campaign!!.title | ||||||
|  |                 binding!!.tvDescription.text = campaign!!.description | ||||||
|  |                 try { | ||||||
|  |                     if (campaign!!.isWLMCampaign) { | ||||||
|  |                         binding!!.tvDates.text = String.format( | ||||||
|  |                             "%1s - %2s", campaign!!.startDate, | ||||||
|  |                             campaign!!.endDate | ||||||
|  |                         ) | ||||||
|  |                     } else { | ||||||
|  |                         val startDate = getIso8601DateFormatShort().parse( | ||||||
|  |                             campaign?.startDate | ||||||
|  |                         ) | ||||||
|  |                         val endDate = getIso8601DateFormatShort().parse( | ||||||
|  |                             campaign?.endDate | ||||||
|  |                         ) | ||||||
|  |                         binding!!.tvDates.text = String.format( | ||||||
|  |                             "%1s - %2s", getExtraShortDateString( | ||||||
|  |                                 startDate!! | ||||||
|  |                             ), getExtraShortDateString(endDate!!) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } catch (e: ParseException) { | ||||||
|  |                     Timber.e(e) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView" | ||||||
|  |         const val WLM_CARD_PREFERENCE: String = "displayWLMCardView" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,123 +0,0 @@ | ||||||
| package fr.free.nrw.commons.campaigns; |  | ||||||
| 
 |  | ||||||
| import android.annotation.SuppressLint; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.campaigns.models.Campaign; |  | ||||||
| import java.text.ParseException; |  | ||||||
| import java.util.Collections; |  | ||||||
| import java.util.Date; |  | ||||||
| import java.util.List; |  | ||||||
| 
 |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import javax.inject.Singleton; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BasePresenter; |  | ||||||
| import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; |  | ||||||
| import fr.free.nrw.commons.utils.CommonsDateUtil; |  | ||||||
| import io.reactivex.Scheduler; |  | ||||||
| import io.reactivex.Single; |  | ||||||
| import io.reactivex.SingleObserver; |  | ||||||
| import io.reactivex.disposables.Disposable; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; |  | ||||||
| import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on |  | ||||||
|  * success and error |  | ||||||
|  */ |  | ||||||
| @Singleton |  | ||||||
| public class CampaignsPresenter implements BasePresenter<ICampaignsView> { |  | ||||||
|     private final OkHttpJsonApiClient okHttpJsonApiClient; |  | ||||||
|     private final Scheduler mainThreadScheduler; |  | ||||||
|     private final Scheduler ioScheduler; |  | ||||||
| 
 |  | ||||||
|     private ICampaignsView view; |  | ||||||
|     private Disposable disposable; |  | ||||||
|     private Campaign campaign; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) { |  | ||||||
|         this.okHttpJsonApiClient = okHttpJsonApiClient; |  | ||||||
|         this.mainThreadScheduler=mainThreadScheduler; |  | ||||||
|         this.ioScheduler=ioScheduler; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void onAttachView(ICampaignsView view) { |  | ||||||
|         this.view = view; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override public void onDetachView() { |  | ||||||
|         this.view = null; |  | ||||||
|         if (disposable != null) { |  | ||||||
|             disposable.dispose(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * make the api call to fetch the campaigns |  | ||||||
|      */ |  | ||||||
|     @SuppressLint("CheckResult") |  | ||||||
|     public void getCampaigns() { |  | ||||||
|         if (view != null && okHttpJsonApiClient != null) { |  | ||||||
|             //If we already have a campaign, lets not make another call |  | ||||||
|             if (this.campaign != null) { |  | ||||||
|                 view.showCampaigns(campaign); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             Single<CampaignResponseDTO> campaigns = okHttpJsonApiClient.getCampaigns(); |  | ||||||
|             campaigns.observeOn(mainThreadScheduler) |  | ||||||
|                 .subscribeOn(ioScheduler) |  | ||||||
|                 .subscribeWith(new SingleObserver<CampaignResponseDTO>() { |  | ||||||
| 
 |  | ||||||
|                     @Override public void onSubscribe(Disposable d) { |  | ||||||
|                         disposable = d; |  | ||||||
|                     } |  | ||||||
| 
 |  | ||||||
|                     @Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) { |  | ||||||
|                         List<Campaign> campaigns = campaignResponseDTO.getCampaigns(); |  | ||||||
|                         if (campaigns == null || campaigns.isEmpty()) { |  | ||||||
|                             Timber.e("The campaigns list is empty"); |  | ||||||
|                             view.showCampaigns(null); |  | ||||||
|                             return; |  | ||||||
|                         } |  | ||||||
|                         Collections.sort(campaigns, (campaign, t1) -> { |  | ||||||
|                             Date date1, date2; |  | ||||||
|                             try { |  | ||||||
| 
 |  | ||||||
|                                 date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate()); |  | ||||||
|                                 date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate()); |  | ||||||
|                             } catch (ParseException e) { |  | ||||||
|                                 e.printStackTrace(); |  | ||||||
|                                 return -1; |  | ||||||
|                             } |  | ||||||
|                             return date1.compareTo(date2); |  | ||||||
|                         }); |  | ||||||
|                         Date campaignEndDate, campaignStartDate; |  | ||||||
|                         Date currentDate = new Date(); |  | ||||||
|                         try { |  | ||||||
|                             for (Campaign aCampaign : campaigns) { |  | ||||||
|                                 campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate()); |  | ||||||
|                                 campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate()); |  | ||||||
|                                 if (campaignEndDate.compareTo(currentDate) >= 0 |  | ||||||
|                                     && campaignStartDate.compareTo(currentDate) <= 0) { |  | ||||||
|                                     campaign = aCampaign; |  | ||||||
|                                     break; |  | ||||||
|                                 } |  | ||||||
|                             } |  | ||||||
|                         } catch (ParseException e) { |  | ||||||
|                             e.printStackTrace(); |  | ||||||
|                         } |  | ||||||
|                         view.showCampaigns(campaign); |  | ||||||
|                     } |  | ||||||
| 
 |  | ||||||
|                     @Override public void onError(Throwable e) { |  | ||||||
|                         Timber.e(e, "could not fetch campaigns"); |  | ||||||
|                     } |  | ||||||
|                 }); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,106 @@ | ||||||
|  | package fr.free.nrw.commons.campaigns | ||||||
|  | 
 | ||||||
|  | import android.annotation.SuppressLint | ||||||
|  | import fr.free.nrw.commons.BasePresenter | ||||||
|  | import fr.free.nrw.commons.campaigns.models.Campaign | ||||||
|  | import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD | ||||||
|  | import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD | ||||||
|  | import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient | ||||||
|  | import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort | ||||||
|  | import io.reactivex.Scheduler | ||||||
|  | import io.reactivex.disposables.Disposable | ||||||
|  | import timber.log.Timber | ||||||
|  | import java.text.ParseException | ||||||
|  | import java.text.SimpleDateFormat | ||||||
|  | import java.util.Date | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | import javax.inject.Singleton | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on | ||||||
|  |  * success and error | ||||||
|  |  */ | ||||||
|  | @Singleton | ||||||
|  | class CampaignsPresenter @Inject constructor( | ||||||
|  |     private val okHttpJsonApiClient: OkHttpJsonApiClient?, | ||||||
|  |     @param:Named(IO_THREAD) private val ioScheduler: Scheduler, | ||||||
|  |     @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler | ||||||
|  | ) : BasePresenter<ICampaignsView?> { | ||||||
|  |     private var view: ICampaignsView? = null | ||||||
|  |     private var disposable: Disposable? = null | ||||||
|  |     private var campaign: Campaign? = null | ||||||
|  | 
 | ||||||
|  |     override fun onAttachView(view: ICampaignsView) { | ||||||
|  |         this.view = view | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onDetachView() { | ||||||
|  |         view = null | ||||||
|  |         disposable?.dispose() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * make the api call to fetch the campaigns | ||||||
|  |      */ | ||||||
|  |     @SuppressLint("CheckResult") | ||||||
|  |     fun getCampaigns() { | ||||||
|  |         if (view != null && okHttpJsonApiClient != null) { | ||||||
|  |             //If we already have a campaign, lets not make another call | ||||||
|  |             if (campaign != null) { | ||||||
|  |                 view!!.showCampaigns(campaign) | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             okHttpJsonApiClient.getCampaigns() | ||||||
|  |                 .observeOn(mainThreadScheduler) | ||||||
|  |                 .subscribeOn(ioScheduler) | ||||||
|  |                 .doOnSubscribe { disposable = it } | ||||||
|  |                 .subscribe({ campaignResponseDTO -> | ||||||
|  |                     val campaigns = campaignResponseDTO?.campaigns?.toMutableList() | ||||||
|  |                     if (campaigns.isNullOrEmpty()) { | ||||||
|  |                         Timber.e("The campaigns list is empty") | ||||||
|  |                         view!!.showCampaigns(null) | ||||||
|  |                     } else { | ||||||
|  |                         sortCampaignsByStartDate(campaigns) | ||||||
|  |                         campaign = findActiveCampaign(campaigns) | ||||||
|  |                         view!!.showCampaigns(campaign) | ||||||
|  |                     } | ||||||
|  |                 }, { | ||||||
|  |                     Timber.e(it, "could not fetch campaigns") | ||||||
|  |                 }) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun sortCampaignsByStartDate(campaigns: MutableList<Campaign>) { | ||||||
|  |         val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() | ||||||
|  |         campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign -> | ||||||
|  |             val date1: Date? | ||||||
|  |             val date2: Date? | ||||||
|  |             try { | ||||||
|  |                 date1 = campaign.startDate?.let { dateFormat.parse(it) } | ||||||
|  |                 date2 = other.startDate?.let { dateFormat.parse(it) } | ||||||
|  |             } catch (e: ParseException) { | ||||||
|  |                 Timber.e(e) | ||||||
|  |                 return@Comparator -1 | ||||||
|  |             } | ||||||
|  |             if (date1 != null && date2 != null) date1.compareTo(date2) else -1 | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun findActiveCampaign(campaigns: List<Campaign>) : Campaign? { | ||||||
|  |         val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() | ||||||
|  |         val currentDate = Date() | ||||||
|  |         return try { | ||||||
|  |             campaigns.firstOrNull { | ||||||
|  |                 val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) } | ||||||
|  |                 val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) } | ||||||
|  |                 campaignStartDate != null && campaignEndDate != null && | ||||||
|  |                         campaignEndDate >= currentDate && campaignStartDate <= currentDate | ||||||
|  |             } | ||||||
|  |         } catch (e: ParseException) { | ||||||
|  |             Timber.e(e, "could not find active campaign") | ||||||
|  |             null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,11 +0,0 @@ | ||||||
| package fr.free.nrw.commons.campaigns; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.MvpView; |  | ||||||
| import fr.free.nrw.commons.campaigns.models.Campaign; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Interface which defines the view contracts of the campaign view |  | ||||||
|  */ |  | ||||||
| public interface ICampaignsView extends MvpView { |  | ||||||
|     void showCampaigns(Campaign campaign); |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | package fr.free.nrw.commons.campaigns | ||||||
|  | 
 | ||||||
|  | import fr.free.nrw.commons.MvpView | ||||||
|  | import fr.free.nrw.commons.campaigns.models.Campaign | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Interface which defines the view contracts of the campaign view | ||||||
|  |  */ | ||||||
|  | interface ICampaignsView : MvpView { | ||||||
|  |     fun showCampaigns(campaign: Campaign?) | ||||||
|  | } | ||||||
|  | @ -3,9 +3,11 @@ package fr.free.nrw.commons.campaigns.models | ||||||
| /** | /** | ||||||
|  * A data class to hold a campaign |  * A data class to hold a campaign | ||||||
|  */ |  */ | ||||||
| data class Campaign(var title: String? = null, | data class Campaign( | ||||||
|  |     var title: String? = null, | ||||||
|     var description: String? = null, |     var description: String? = null, | ||||||
|     var startDate: String? = null, |     var startDate: String? = null, | ||||||
|     var endDate: String? = null, |     var endDate: String? = null, | ||||||
|     var link: String? = null, |     var link: String? = null, | ||||||
|                     var isWLMCampaign: Boolean = false) |     var isWLMCampaign: Boolean = false, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | @ -8,16 +8,19 @@ import fr.free.nrw.commons.utils.StringSortingUtils | ||||||
| import io.reactivex.Observable | import io.reactivex.Observable | ||||||
| import io.reactivex.functions.Function4 | import io.reactivex.functions.Function4 | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import java.util.* | import java.util.Calendar | ||||||
|  | import java.util.Date | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * The model class for categories in upload |  * The model class for categories in upload | ||||||
|  */ |  */ | ||||||
| class CategoriesModel @Inject constructor( | class CategoriesModel | ||||||
|  |     @Inject | ||||||
|  |     constructor( | ||||||
|         private val categoryClient: CategoryClient, |         private val categoryClient: CategoryClient, | ||||||
|         private val categoryDao: CategoryDao, |         private val categoryDao: CategoryDao, | ||||||
|     private val gpsCategoryModel: GpsCategoryModel |         private val gpsCategoryModel: GpsCategoryModel, | ||||||
|     ) { |     ) { | ||||||
|         private val selectedCategories: MutableList<CategoryItem> = mutableListOf() |         private val selectedCategories: MutableList<CategoryItem> = mutableListOf() | ||||||
| 
 | 
 | ||||||
|  | @ -27,30 +30,43 @@ class CategoriesModel @Inject constructor( | ||||||
|         private var selectedExistingCategories: MutableList<String> = mutableListOf() |         private var selectedExistingCategories: MutableList<String> = mutableListOf() | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|      * Returns if the item contains an year |          * Returns true if an item is considered to be a spammy category which should be ignored | ||||||
|      * @param item |          * | ||||||
|  |          * @param item a category item that needs to be validated to know if it is spammy or not | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|     fun containsYear(item: String): Boolean { |         fun isSpammyCategory(item: String): Boolean { | ||||||
|             // Check for current and previous year to exclude these categories from removal |             // Check for current and previous year to exclude these categories from removal | ||||||
|             val now = Calendar.getInstance() |             val now = Calendar.getInstance() | ||||||
|         val year = now[Calendar.YEAR] |             val curYear = now[Calendar.YEAR] | ||||||
|         val yearInString = year.toString() |             val curYearInString = curYear.toString() | ||||||
|         val prevYear = year - 1 |             val prevYear = curYear - 1 | ||||||
|             val prevYearInString = prevYear.toString() |             val prevYearInString = prevYear.toString() | ||||||
|             Timber.d("Previous year: %s", prevYearInString) |             Timber.d("Previous year: %s", prevYearInString) | ||||||
| 
 | 
 | ||||||
|         //Check if item contains a 4-digit word anywhere within the string (.* is wildcard) |             val mentionsDecade = item.matches(".*0s.*".toRegex()) | ||||||
|  |             val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) | ||||||
|  |             val spammyCategory = | ||||||
|  |                 item.matches("(.*)needing(.*)".toRegex()) || | ||||||
|  |                     item.matches("(.*)taken on(.*)".toRegex()) | ||||||
|  | 
 | ||||||
|  |             // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) | ||||||
|  |             if (spammyCategory) { | ||||||
|  |                 return true | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (mentionsDecade) { | ||||||
|  |                 // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 | ||||||
|  |                 // Example: "2020s" is OK, but "1920s" is not (and should be skipped) | ||||||
|  |                 return !recentDecade | ||||||
|  |             } else { | ||||||
|  |                 // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year | ||||||
|  |                 // anywhere within the string (.* is wildcard) (Issue #47) | ||||||
|                 // And that item does not equal the current year or previous year |                 // And that item does not equal the current year or previous year | ||||||
|         //And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750) |                 return item.matches(".*(19|20)\\d{2}.*".toRegex()) && | ||||||
|         //Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029 |                     !item.contains(curYearInString) && | ||||||
|         return item.matches(".*(19|20)\\d{2}.*".toRegex()) |                     !item.contains(prevYearInString) | ||||||
|                 && !item.contains(yearInString) |             } | ||||||
|                 && !item.contains(prevYearInString) |  | ||||||
|                 || item.matches("(.*)needing(.*)".toRegex()) |  | ||||||
|                 || item.matches("(.*)taken on(.*)".toRegex()) |  | ||||||
|                 || item.matches(".*0s.*".toRegex()) |  | ||||||
|                 && !item.matches(".*(200|201)0s.*".toRegex()) |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|  | @ -62,7 +78,13 @@ class CategoriesModel @Inject constructor( | ||||||
| 
 | 
 | ||||||
|             // Newly used category... |             // Newly used category... | ||||||
|             if (category == null) { |             if (category == null) { | ||||||
|             category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) |                 category = Category( | ||||||
|  |                     null, item.name, | ||||||
|  |                     item.description, | ||||||
|  |                     item.thumbnail, | ||||||
|  |                     Date(), | ||||||
|  |                     0 | ||||||
|  |                 ) | ||||||
|             } |             } | ||||||
|             category.incTimesUsed() |             category.incTimesUsed() | ||||||
|             categoryDao.save(category) |             categoryDao.save(category) | ||||||
|  | @ -77,27 +99,27 @@ class CategoriesModel @Inject constructor( | ||||||
|         fun searchAll( |         fun searchAll( | ||||||
|             term: String, |             term: String, | ||||||
|             imageTitleList: List<String>, |             imageTitleList: List<String>, | ||||||
|         selectedDepictions: List<DepictedItem> |             selectedDepictions: List<DepictedItem>, | ||||||
|     ): Observable<List<CategoryItem>> { |         ): Observable<List<CategoryItem>> = | ||||||
|         return suggestionsOrSearch(term, imageTitleList, selectedDepictions) |             suggestionsOrSearch(term, imageTitleList, selectedDepictions) | ||||||
|                 .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } |                 .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         private fun suggestionsOrSearch( |         private fun suggestionsOrSearch( | ||||||
|             term: String, |             term: String, | ||||||
|             imageTitleList: List<String>, |             imageTitleList: List<String>, | ||||||
|         selectedDepictions: List<DepictedItem> |             selectedDepictions: List<DepictedItem>, | ||||||
|     ): Observable<List<CategoryItem>> { |         ): Observable<List<CategoryItem>> = | ||||||
|         return if (TextUtils.isEmpty(term)) |             if (TextUtils.isEmpty(term)) { | ||||||
|                 Observable.combineLatest( |                 Observable.combineLatest( | ||||||
|                     categoriesFromDepiction(selectedDepictions), |                     categoriesFromDepiction(selectedDepictions), | ||||||
|                     gpsCategoryModel.categoriesFromLocation, |                     gpsCategoryModel.categoriesFromLocation, | ||||||
|                     titleCategories(imageTitleList), |                     titleCategories(imageTitleList), | ||||||
|                     Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), |                     Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), | ||||||
|                 Function4(::combine) |                     Function4(::combine), | ||||||
|                 ) |                 ) | ||||||
|         else |             } else { | ||||||
|             categoryClient.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) |                 categoryClient | ||||||
|  |                     .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) | ||||||
|                     .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } |                     .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } | ||||||
|                     .toObservable() |                     .toObservable() | ||||||
|             } |             } | ||||||
|  | @ -105,23 +127,63 @@ class CategoriesModel @Inject constructor( | ||||||
|         /** |         /** | ||||||
|          * Fetches details of every category associated with selected depictions, converts them into |          * Fetches details of every category associated with selected depictions, converts them into | ||||||
|          * CategoryItem and returns them in a list. |          * CategoryItem and returns them in a list. | ||||||
|  |          * If a selected depiction has no categories, the categories in which its P18 belongs are | ||||||
|  |          * returned in the list. | ||||||
|          * |          * | ||||||
|          * @param selectedDepictions selected DepictItems |          * @param selectedDepictions selected DepictItems | ||||||
|          * @return List of CategoryItem associated with selected depictions |          * @return List of CategoryItem associated with selected depictions | ||||||
|          */ |          */ | ||||||
|     private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): |         private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? { | ||||||
|             Observable<MutableList<CategoryItem>>? { |             val observables =  selectedDepictions.map { depictedItem -> | ||||||
|         return Observable.fromIterable( |                 if (depictedItem.commonsCategories.isEmpty()) { | ||||||
|                 selectedDepictions.map { it.commonsCategories }.flatten()) |                     if (depictedItem.primaryImage == null) { | ||||||
|                 .map { categoryItem -> |                         return@map Observable.just(emptyList<CategoryItem>()) | ||||||
|                     categoryClient.getCategoriesByName(categoryItem.name, |                     } | ||||||
|                         categoryItem.name, SEARCH_CATS_LIMIT).map { |                     Observable.just( | ||||||
| 
 |                             depictedItem.primaryImage | ||||||
|                         CategoryItem(it[0].name, it[0].description, |                         ).map { image -> | ||||||
|                             it[0].thumbnail, it[0].isSelected) |                             categoryClient | ||||||
| 
 |                                 .getCategoriesOfImage( | ||||||
|  |                                     image, | ||||||
|  |                                     SEARCH_CATS_LIMIT, | ||||||
|  |                                 ).map { | ||||||
|  |                                     it.map { category -> | ||||||
|  |                                         CategoryItem( | ||||||
|  |                                             category.name, | ||||||
|  |                                             category.description, | ||||||
|  |                                             category.thumbnail, | ||||||
|  |                                             category.isSelected, | ||||||
|  |                                         ) | ||||||
|  |                                     } | ||||||
|                                 }.blockingGet() |                                 }.blockingGet() | ||||||
|                 }.toList().toObservable() |                         }.flatMapIterable { it }.toList() | ||||||
|  |                         .toObservable() | ||||||
|  |                 } else { | ||||||
|  |                     Observable | ||||||
|  |                         .fromIterable( | ||||||
|  |                             depictedItem.commonsCategories, | ||||||
|  |                         ).map { categoryItem -> | ||||||
|  |                             categoryClient | ||||||
|  |                                 .getCategoriesByName( | ||||||
|  |                                     categoryItem.name, | ||||||
|  |                                     categoryItem.name, | ||||||
|  |                                     SEARCH_CATS_LIMIT, | ||||||
|  |                                 ).map { | ||||||
|  |                                     CategoryItem( | ||||||
|  |                                         it[0].name, | ||||||
|  |                                         it[0].description, | ||||||
|  |                                         it[0].thumbnail, | ||||||
|  |                                         it[0].isSelected, | ||||||
|  |                                     ) | ||||||
|  |                                 }.blockingGet() | ||||||
|  |                         }.toList() | ||||||
|  |                         .toObservable() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return Observable.concat(observables) | ||||||
|  |                 .scan(mutableListOf<CategoryItem>()) { accumulator, currentList -> | ||||||
|  |                     accumulator.apply { addAll(currentList) } | ||||||
|  |                 } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|  | @ -131,74 +193,80 @@ class CategoriesModel @Inject constructor( | ||||||
|          * @param categoryNames selected Categories |          * @param categoryNames selected Categories | ||||||
|          * @return List of CategoryItem |          * @return List of CategoryItem | ||||||
|          */ |          */ | ||||||
|      fun getCategoriesByName(categoryNames: List<String>): |         fun getCategoriesByName(categoryNames: List<String>): Observable<MutableList<CategoryItem>>? = | ||||||
|             Observable<MutableList<CategoryItem>>? { |             Observable | ||||||
|         return Observable.fromIterable(categoryNames) |                 .fromIterable(categoryNames) | ||||||
|                 .map { categoryName -> |                 .map { categoryName -> | ||||||
|                     buildCategories(categoryName) |                     buildCategories(categoryName) | ||||||
|             } |                 }.filter { categoryItem -> | ||||||
|             .filter { categoryItem -> |  | ||||||
|                     categoryItem.name != "Hidden" |                     categoryItem.name != "Hidden" | ||||||
|             } |                 }.toList() | ||||||
|             .toList().toObservable() |                 .toObservable() | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Fetches the categories and converts them into CategoryItem |          * Fetches the categories and converts them into CategoryItem | ||||||
|          */ |          */ | ||||||
|     fun buildCategories(categoryName: String): CategoryItem { |         fun buildCategories(categoryName: String): CategoryItem = | ||||||
|         return categoryClient.getCategoriesByName(categoryName, |             categoryClient | ||||||
|             categoryName, SEARCH_CATS_LIMIT).map { |                 .getCategoriesByName( | ||||||
|  |                     categoryName, | ||||||
|  |                     categoryName, | ||||||
|  |                     SEARCH_CATS_LIMIT, | ||||||
|  |                 ).map { | ||||||
|                     if (it.isNotEmpty()) { |                     if (it.isNotEmpty()) { | ||||||
|                         CategoryItem( |                         CategoryItem( | ||||||
|                     it[0].name, it[0].description, |                             it[0].name, | ||||||
|                     it[0].thumbnail, it[0].isSelected |                             it[0].description, | ||||||
|  |                             it[0].thumbnail, | ||||||
|  |                             it[0].isSelected, | ||||||
|                         ) |                         ) | ||||||
|                     } else { |                     } else { | ||||||
|                         CategoryItem( |                         CategoryItem( | ||||||
|                     "Hidden", "Hidden", |                             "Hidden", | ||||||
|                     "hidden", false |                             "Hidden", | ||||||
|  |                             "hidden", | ||||||
|  |                             false, | ||||||
|                         ) |                         ) | ||||||
|                     } |                     } | ||||||
|                 }.blockingGet() |                 }.blockingGet() | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         private fun combine( |         private fun combine( | ||||||
|             depictionCategories: List<CategoryItem>, |             depictionCategories: List<CategoryItem>, | ||||||
|             locationCategories: List<CategoryItem>, |             locationCategories: List<CategoryItem>, | ||||||
|             titles: List<CategoryItem>, |             titles: List<CategoryItem>, | ||||||
|         recents: List<CategoryItem> |             recents: List<CategoryItem>, | ||||||
|         ) = depictionCategories + locationCategories + titles + recents |         ) = depictionCategories + locationCategories + titles + recents | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|         /** |         /** | ||||||
|          * Returns title based categories |          * Returns title based categories | ||||||
|          * @param titleList |          * @param titleList | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|         private fun titleCategories(titleList: List<String>) = |         private fun titleCategories(titleList: List<String>) = | ||||||
|         if (titleList.isNotEmpty()) |             if (titleList.isNotEmpty()) { | ||||||
|                 Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> |                 Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> | ||||||
|                     searchResults.map { it as List<CategoryItem> }.flatten() |                     searchResults.map { it as List<CategoryItem> }.flatten() | ||||||
|                 } |                 } | ||||||
|         else |             } else { | ||||||
|                 Observable.just(emptyList()) |                 Observable.just(emptyList()) | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Return category for single title |          * Return category for single title | ||||||
|          * @param title |          * @param title | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|     private fun getTitleCategories(title: String): Observable<List<CategoryItem>> { |         private fun getTitleCategories(title: String): Observable<List<CategoryItem>> = | ||||||
|         return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() |             categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Handles category item selection |          * Handles category item selection | ||||||
|          * @param item |          * @param item | ||||||
|          */ |          */ | ||||||
|     fun onCategoryItemClicked(item: CategoryItem, media: Media?) { |         fun onCategoryItemClicked( | ||||||
|  |             item: CategoryItem, | ||||||
|  |             media: Media?, | ||||||
|  |         ) { | ||||||
|             if (media == null) { |             if (media == null) { | ||||||
|                 if (item.isSelected) { |                 if (item.isSelected) { | ||||||
|                     selectedCategories.add(item) |                     selectedCategories.add(item) | ||||||
|  | @ -234,9 +302,7 @@ class CategoriesModel @Inject constructor( | ||||||
|          * Get Selected Categories |          * Get Selected Categories | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|     fun getSelectedCategories(): List<CategoryItem> { |         fun getSelectedCategories(): List<CategoryItem> = selectedCategories | ||||||
|         return selectedCategories |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Cleanup the existing in memory cache's |          * Cleanup the existing in memory cache's | ||||||
|  | @ -255,9 +321,7 @@ class CategoriesModel @Inject constructor( | ||||||
|          * |          * | ||||||
|          * @return selected existing categories |          * @return selected existing categories | ||||||
|          */ |          */ | ||||||
|     fun getSelectedExistingCategories(): List<String> { |         fun getSelectedExistingCategories(): List<String> = selectedExistingCategories | ||||||
|         return selectedExistingCategories |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Initialize existing categories |          * Initialize existing categories | ||||||
|  |  | ||||||
|  | @ -1,115 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| import android.net.Uri; |  | ||||||
| 
 |  | ||||||
| import java.util.Date; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Represents a category |  | ||||||
|  */ |  | ||||||
| public class Category { |  | ||||||
|     private Uri contentUri; |  | ||||||
|     private String name; |  | ||||||
|     private String description; |  | ||||||
|     private String thumbnail; |  | ||||||
|     private Date lastUsed; |  | ||||||
|     private int timesUsed; |  | ||||||
| 
 |  | ||||||
|     public Category() { |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public Category(Uri contentUri, String name, String description, String thumbnail, Date lastUsed, int timesUsed) { |  | ||||||
|         this.contentUri = contentUri; |  | ||||||
|         this.name = name; |  | ||||||
|         this.description = description; |  | ||||||
|         this.thumbnail = thumbnail; |  | ||||||
|         this.lastUsed = lastUsed; |  | ||||||
|         this.timesUsed = timesUsed; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets name |  | ||||||
|      * |  | ||||||
|      * @return name |  | ||||||
|      */ |  | ||||||
|     public String getName() { |  | ||||||
|         return name; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Modifies name |  | ||||||
|      * |  | ||||||
|      * @param name Category name |  | ||||||
|      */ |  | ||||||
|     public void setName(String name) { |  | ||||||
|         this.name = name; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets last used date |  | ||||||
|      * |  | ||||||
|      * @return Last used date |  | ||||||
|      */ |  | ||||||
|     public Date getLastUsed() { |  | ||||||
|         // warning: Date objects are mutable. |  | ||||||
|         return (Date)lastUsed.clone(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Generates new last used date |  | ||||||
|      */ |  | ||||||
|     private void touch() { |  | ||||||
|         lastUsed = new Date(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets no. of times the category is used |  | ||||||
|      * |  | ||||||
|      * @return no. of times used |  | ||||||
|      */ |  | ||||||
|     public int getTimesUsed() { |  | ||||||
|         return timesUsed; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Increments timesUsed by 1 and sets last used date as now. |  | ||||||
|      */ |  | ||||||
|     public void incTimesUsed() { |  | ||||||
|         timesUsed++; |  | ||||||
|         touch(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets the content URI for this category |  | ||||||
|      * |  | ||||||
|      * @return content URI |  | ||||||
|      */ |  | ||||||
|     public Uri getContentUri() { |  | ||||||
|         return contentUri; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Modifies the content URI - marking this category as already saved in the database |  | ||||||
|      * |  | ||||||
|      * @param contentUri the content URI |  | ||||||
|      */ |  | ||||||
|     public void setContentUri(Uri contentUri) { |  | ||||||
|         this.contentUri = contentUri; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public String getDescription() { |  | ||||||
|         return description; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public String getThumbnail() { |  | ||||||
|         return thumbnail; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void setDescription(final String description) { |  | ||||||
|         this.description = description; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void setThumbnail(final String thumbnail) { |  | ||||||
|         this.thumbnail = thumbnail; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										17
									
								
								app/src/main/java/fr/free/nrw/commons/category/Category.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/src/main/java/fr/free/nrw/commons/category/Category.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | import android.net.Uri | ||||||
|  | import java.util.Date | ||||||
|  | 
 | ||||||
|  | data class Category( | ||||||
|  |     var contentUri: Uri? = null, | ||||||
|  |     val name: String? = null, | ||||||
|  |     val description: String? = null, | ||||||
|  |     val thumbnail: String? = null, | ||||||
|  |     val lastUsed: Date? = null, | ||||||
|  |     var timesUsed: Int = 0 | ||||||
|  | ) { | ||||||
|  |     fun incTimesUsed() { | ||||||
|  |         timesUsed++ | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,5 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| public interface CategoryClickedListener { |  | ||||||
|     void categoryClicked(CategoryItem item); |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | interface CategoryClickedListener { | ||||||
|  |     fun categoryClicked(item: CategoryItem) | ||||||
|  | } | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| package fr.free.nrw.commons.category | package fr.free.nrw.commons.category | ||||||
| 
 | 
 | ||||||
| import io.reactivex.Single |  | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||||
|  | import io.reactivex.Single | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
| 
 | 
 | ||||||
|  | @ -15,9 +15,11 @@ const val CATEGORY_NEEDING_CATEGORIES = "needing categories" | ||||||
|  * Category Client to handle custom calls to Commons MediaWiki APIs |  * Category Client to handle custom calls to Commons MediaWiki APIs | ||||||
|  */ |  */ | ||||||
| @Singleton | @Singleton | ||||||
| class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) : | class CategoryClient | ||||||
|     ContinuationClient<MwQueryResponse, CategoryItem>() { |     @Inject | ||||||
| 
 |     constructor( | ||||||
|  |         private val categoryInterface: CategoryInterface, | ||||||
|  |     ) : ContinuationClient<MwQueryResponse, CategoryItem>() { | ||||||
|         /** |         /** | ||||||
|          * Searches for categories containing the specified string. |          * Searches for categories containing the specified string. | ||||||
|          * |          * | ||||||
|  | @ -27,10 +29,11 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|         @JvmOverloads |         @JvmOverloads | ||||||
|     fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0): |         fun searchCategories( | ||||||
|             Single<List<CategoryItem>> { |             filter: String?, | ||||||
|         return responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) |             itemLimit: Int, | ||||||
|     } |             offset: Int = 0, | ||||||
|  |         ): Single<List<CategoryItem>> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Searches for categories starting with the specified string. |          * Searches for categories starting with the specified string. | ||||||
|  | @ -41,12 +44,14 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|         @JvmOverloads |         @JvmOverloads | ||||||
|     fun searchCategoriesForPrefix(prefix: String?, itemLimit: Int, offset: Int = 0): |         fun searchCategoriesForPrefix( | ||||||
|             Single<List<CategoryItem>> { |             prefix: String?, | ||||||
|         return responseMapper( |             itemLimit: Int, | ||||||
|             categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset) |             offset: Int = 0, | ||||||
|  |         ): Single<List<CategoryItem>> = | ||||||
|  |             responseMapper( | ||||||
|  |                 categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset), | ||||||
|             ) |             ) | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * Fetches categories starting and ending with a specified name. |          * Fetches categories starting and ending with a specified name. | ||||||
|  | @ -58,13 +63,38 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
|          * @return MwQueryResponse |          * @return MwQueryResponse | ||||||
|          */ |          */ | ||||||
|         @JvmOverloads |         @JvmOverloads | ||||||
|     fun getCategoriesByName(startingCategoryName: String?, endingCategoryName: String?, |         fun getCategoriesByName( | ||||||
|                             itemLimit: Int, offset: Int = 0): Single<List<CategoryItem>> { |             startingCategoryName: String?, | ||||||
|         return responseMapper( |             endingCategoryName: String?, | ||||||
|             categoryInterface.getCategoriesByName(startingCategoryName, endingCategoryName, |             itemLimit: Int, | ||||||
|                 itemLimit, offset) |             offset: Int = 0, | ||||||
|  |         ): Single<List<CategoryItem>> = | ||||||
|  |             responseMapper( | ||||||
|  |                 categoryInterface.getCategoriesByName( | ||||||
|  |                     startingCategoryName, | ||||||
|  |                     endingCategoryName, | ||||||
|  |                     itemLimit, | ||||||
|  |                     offset, | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         /** | ||||||
|  |          * Fetches categories belonging to an image (P18 of some wikidata entity). | ||||||
|  |          * | ||||||
|  |          * @param image P18 of some wikidata entity | ||||||
|  |          * @param itemLimit How many categories to return | ||||||
|  |          * @return Single Observable emitting the list of categories | ||||||
|  |          */ | ||||||
|  |         fun getCategoriesOfImage( | ||||||
|  |             image: String, | ||||||
|  |             itemLimit: Int, | ||||||
|  |         ): Single<List<CategoryItem>> = | ||||||
|  |             responseMapper( | ||||||
|  |                 categoryInterface.getCategoriesByTitles( | ||||||
|  |                     "File:${image}", | ||||||
|  |                     itemLimit, | ||||||
|  |                 ), | ||||||
|             ) |             ) | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * The method takes categoryName as input and returns a List of Subcategories |          * The method takes categoryName as input and returns a List of Subcategories | ||||||
|  | @ -73,13 +103,13 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
|          * @param categoryName Category name as defined on commons |          * @param categoryName Category name as defined on commons | ||||||
|          * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. |          * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. | ||||||
|          */ |          */ | ||||||
|     fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> { |         fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> = | ||||||
|         return continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { |             continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||||
|                 categoryInterface.getSubCategoryList( |                 categoryInterface.getSubCategoryList( | ||||||
|                 categoryName, it |                     categoryName, | ||||||
|  |                     it, | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         /** |         /** | ||||||
|          * The method takes categoryName as input and returns a List of parent categories |          * The method takes categoryName as input and returns a List of parent categories | ||||||
|  | @ -88,11 +118,10 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
|          * @param categoryName Category name as defined on commons |          * @param categoryName Category name as defined on commons | ||||||
|          * @return |          * @return | ||||||
|          */ |          */ | ||||||
|     fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> { |         fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> = | ||||||
|         return continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { |             continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||||
|                 categoryInterface.getParentCategoryList(categoryName, it) |                 categoryInterface.getParentCategoryList(categoryName, it) | ||||||
|             } |             } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|         fun resetSubCategoryContinuation(category: String) { |         fun resetSubCategoryContinuation(category: String) { | ||||||
|             resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) |             resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) | ||||||
|  | @ -104,20 +133,25 @@ class CategoryClient @Inject constructor(private val categoryInterface: Category | ||||||
| 
 | 
 | ||||||
|         override fun responseMapper( |         override fun responseMapper( | ||||||
|             networkResult: Single<MwQueryResponse>, |             networkResult: Single<MwQueryResponse>, | ||||||
|         key: String? |             key: String?, | ||||||
|     ): Single<List<CategoryItem>> { |         ): Single<List<CategoryItem>> = | ||||||
|         return networkResult |             networkResult | ||||||
|                 .map { |                 .map { | ||||||
|                     handleContinuationResponse(it.continuation(), key) |                     handleContinuationResponse(it.continuation(), key) | ||||||
|                     it.query()?.pages() ?: emptyList() |                     it.query()?.pages() ?: emptyList() | ||||||
|             } |  | ||||||
|             .map { |  | ||||||
|                 it.filter { |  | ||||||
|                     page -> page.categoryInfo() == null || !page.categoryInfo().isHidden |  | ||||||
|                 }.map { |                 }.map { | ||||||
|                     CategoryItem(it.title().replace(CATEGORY_PREFIX, ""), |                     it | ||||||
|                         it.description().toString(), it.thumbUrl().toString(), false) |                         .filter { page -> | ||||||
|                 } |                             // Null check is not redundant because some values could be null | ||||||
|  |                             // for mocks when running unit tests | ||||||
|  |                             page.categoryInfo()?.isHidden != true | ||||||
|  |                         }.map { | ||||||
|  |                             CategoryItem( | ||||||
|  |                                 it.title().replace(CATEGORY_PREFIX, ""), | ||||||
|  |                                 it.description().toString(), | ||||||
|  |                                 it.thumbUrl().toString(), | ||||||
|  |                                 false, | ||||||
|  |                             ) | ||||||
|                         } |                         } | ||||||
|                 } |                 } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,169 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| import android.content.ContentValues; |  | ||||||
| import android.content.UriMatcher; |  | ||||||
| import android.database.Cursor; |  | ||||||
| import android.database.sqlite.SQLiteDatabase; |  | ||||||
| import android.database.sqlite.SQLiteQueryBuilder; |  | ||||||
| import android.net.Uri; |  | ||||||
| import android.text.TextUtils; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| 
 |  | ||||||
| import javax.inject.Inject; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import fr.free.nrw.commons.data.DBOpenHelper; |  | ||||||
| import fr.free.nrw.commons.di.CommonsDaggerContentProvider; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| import static android.content.UriMatcher.NO_MATCH; |  | ||||||
| import static fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS; |  | ||||||
| import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID; |  | ||||||
| import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME; |  | ||||||
| 
 |  | ||||||
| public class CategoryContentProvider extends CommonsDaggerContentProvider { |  | ||||||
| 
 |  | ||||||
|     // For URI matcher |  | ||||||
|     private static final int CATEGORIES = 1; |  | ||||||
|     private static final int CATEGORIES_ID = 2; |  | ||||||
|     private static final String BASE_PATH = "categories"; |  | ||||||
| 
 |  | ||||||
|     public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH); |  | ||||||
| 
 |  | ||||||
|     private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH); |  | ||||||
| 
 |  | ||||||
|     static { |  | ||||||
|         uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES); |  | ||||||
|         uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public static Uri uriForId(int id) { |  | ||||||
|         return Uri.parse(BASE_URI.toString() + "/" + id); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Inject DBOpenHelper dbOpenHelper; |  | ||||||
| 
 |  | ||||||
|     @SuppressWarnings("ConstantConditions") |  | ||||||
|     @Override |  | ||||||
|     public Cursor query(@NonNull Uri uri, String[] projection, String selection, |  | ||||||
|                         String[] selectionArgs, String sortOrder) { |  | ||||||
|         SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); |  | ||||||
|         queryBuilder.setTables(TABLE_NAME); |  | ||||||
| 
 |  | ||||||
|         int uriType = uriMatcher.match(uri); |  | ||||||
| 
 |  | ||||||
|         SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); |  | ||||||
|         Cursor cursor; |  | ||||||
| 
 |  | ||||||
|         switch (uriType) { |  | ||||||
|             case CATEGORIES: |  | ||||||
|                 cursor = queryBuilder.query(db, projection, selection, selectionArgs, |  | ||||||
|                         null, null, sortOrder); |  | ||||||
|                 break; |  | ||||||
|             case CATEGORIES_ID: |  | ||||||
|                 cursor = queryBuilder.query(db, |  | ||||||
|                         ALL_FIELDS, |  | ||||||
|                         "_id = ?", |  | ||||||
|                         new String[]{uri.getLastPathSegment()}, |  | ||||||
|                         null, |  | ||||||
|                         null, |  | ||||||
|                         sortOrder |  | ||||||
|                 ); |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new IllegalArgumentException("Unknown URI" + uri); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         cursor.setNotificationUri(getContext().getContentResolver(), uri); |  | ||||||
| 
 |  | ||||||
|         return cursor; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public String getType(@NonNull Uri uri) { |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @SuppressWarnings("ConstantConditions") |  | ||||||
|     @Override |  | ||||||
|     public Uri insert(@NonNull Uri uri, ContentValues contentValues) { |  | ||||||
|         int uriType = uriMatcher.match(uri); |  | ||||||
|         SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); |  | ||||||
|         long id; |  | ||||||
|         switch (uriType) { |  | ||||||
|             case CATEGORIES: |  | ||||||
|                 id = sqlDB.insert(TABLE_NAME, null, contentValues); |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new IllegalArgumentException("Unknown URI: " + uri); |  | ||||||
|         } |  | ||||||
|         getContext().getContentResolver().notifyChange(uri, null); |  | ||||||
|         return Uri.parse(BASE_URI + "/" + id); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public int delete(@NonNull Uri uri, String s, String[] strings) { |  | ||||||
|         return 0; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @SuppressWarnings("ConstantConditions") |  | ||||||
|     @Override |  | ||||||
|     public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) { |  | ||||||
|         Timber.d("Hello, bulk insert! (CategoryContentProvider)"); |  | ||||||
|         int uriType = uriMatcher.match(uri); |  | ||||||
|         SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); |  | ||||||
|         sqlDB.beginTransaction(); |  | ||||||
|         switch (uriType) { |  | ||||||
|             case CATEGORIES: |  | ||||||
|                 for (ContentValues value : values) { |  | ||||||
|                     Timber.d("Inserting! %s", value); |  | ||||||
|                     sqlDB.insert(TABLE_NAME, null, value); |  | ||||||
|                 } |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new IllegalArgumentException("Unknown URI: " + uri); |  | ||||||
|         } |  | ||||||
|         sqlDB.setTransactionSuccessful(); |  | ||||||
|         sqlDB.endTransaction(); |  | ||||||
|         getContext().getContentResolver().notifyChange(uri, null); |  | ||||||
|         return values.length; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @SuppressWarnings("ConstantConditions") |  | ||||||
|     @Override |  | ||||||
|     public int update(@NonNull Uri uri, ContentValues contentValues, String selection, |  | ||||||
|                       String[] selectionArgs) { |  | ||||||
|         /* |  | ||||||
|         SQL Injection warnings: First, note that we're not exposing this to the |  | ||||||
|         outside world (exported="false"). Even then, we should make sure to sanitize |  | ||||||
|         all user input appropriately. Input that passes through ContentValues |  | ||||||
|         should be fine. So only issues are those that pass in via concating. |  | ||||||
| 
 |  | ||||||
|         In here, the only concat created argument is for id. It is cast to an int, |  | ||||||
|         and will error out otherwise. |  | ||||||
|          */ |  | ||||||
|         int uriType = uriMatcher.match(uri); |  | ||||||
|         SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); |  | ||||||
|         int rowsUpdated; |  | ||||||
|         switch (uriType) { |  | ||||||
|             case CATEGORIES_ID: |  | ||||||
|                 if (TextUtils.isEmpty(selection)) { |  | ||||||
|                     int id = Integer.valueOf(uri.getLastPathSegment()); |  | ||||||
|                     rowsUpdated = sqlDB.update(TABLE_NAME, |  | ||||||
|                             contentValues, |  | ||||||
|                             COLUMN_ID + " = ?", |  | ||||||
|                             new String[]{String.valueOf(id)}); |  | ||||||
|                 } else { |  | ||||||
|                     throw new IllegalArgumentException( |  | ||||||
|                             "Parameter `selection` should be empty when updating an ID"); |  | ||||||
|                 } |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType); |  | ||||||
|         } |  | ||||||
|         getContext().getContentResolver().notifyChange(uri, null); |  | ||||||
|         return rowsUpdated; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
|  | @ -0,0 +1,205 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | import android.content.ContentValues | ||||||
|  | import android.content.UriMatcher | ||||||
|  | import android.content.UriMatcher.NO_MATCH | ||||||
|  | import android.database.Cursor | ||||||
|  | import android.database.sqlite.SQLiteDatabase | ||||||
|  | import android.database.sqlite.SQLiteQueryBuilder | ||||||
|  | import android.net.Uri | ||||||
|  | import android.text.TextUtils | ||||||
|  | import androidx.annotation.NonNull | ||||||
|  | import fr.free.nrw.commons.BuildConfig | ||||||
|  | import fr.free.nrw.commons.data.DBOpenHelper | ||||||
|  | import fr.free.nrw.commons.di.CommonsDaggerContentProvider | ||||||
|  | import timber.log.Timber | ||||||
|  | import javax.inject.Inject | ||||||
|  | 
 | ||||||
|  | class CategoryContentProvider : CommonsDaggerContentProvider() { | ||||||
|  | 
 | ||||||
|  |     private val uriMatcher = UriMatcher(NO_MATCH).apply { | ||||||
|  |         addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES) | ||||||
|  |         addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     lateinit var dbOpenHelper: DBOpenHelper | ||||||
|  | 
 | ||||||
|  |     @SuppressWarnings("ConstantConditions") | ||||||
|  |     override fun query(uri: Uri, projection: Array<String>?, selection: String?, | ||||||
|  |                        selectionArgs: Array<String>?, sortOrder: String?): Cursor? { | ||||||
|  |         val queryBuilder = SQLiteQueryBuilder().apply { | ||||||
|  |             tables = TABLE_NAME | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val uriType = uriMatcher.match(uri) | ||||||
|  |         val db = dbOpenHelper.readableDatabase | ||||||
|  | 
 | ||||||
|  |         val cursor: Cursor? = when (uriType) { | ||||||
|  |             CATEGORIES -> queryBuilder.query( | ||||||
|  |                 db, | ||||||
|  |                 projection, | ||||||
|  |                 selection, | ||||||
|  |                 selectionArgs, | ||||||
|  |                 null, | ||||||
|  |                 null, | ||||||
|  |                 sortOrder | ||||||
|  |             ) | ||||||
|  |             CATEGORIES_ID -> queryBuilder.query( | ||||||
|  |                 db, | ||||||
|  |                 ALL_FIELDS, | ||||||
|  |                 "_id = ?", | ||||||
|  |                 arrayOf(uri.lastPathSegment), | ||||||
|  |                 null, | ||||||
|  |                 null, | ||||||
|  |                 sortOrder | ||||||
|  |             ) | ||||||
|  |             else -> throw IllegalArgumentException("Unknown URI $uri") | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         cursor?.setNotificationUri(context?.contentResolver, uri) | ||||||
|  |         return cursor | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getType(uri: Uri): String? { | ||||||
|  |         return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressWarnings("ConstantConditions") | ||||||
|  |     override fun insert(uri: Uri, contentValues: ContentValues?): Uri? { | ||||||
|  |         val uriType = uriMatcher.match(uri) | ||||||
|  |         val sqlDB = dbOpenHelper.writableDatabase | ||||||
|  |         val id: Long | ||||||
|  |         when (uriType) { | ||||||
|  |             CATEGORIES -> { | ||||||
|  |                 id = sqlDB.insert(TABLE_NAME, null, contentValues) | ||||||
|  |             } | ||||||
|  |             else -> throw IllegalArgumentException("Unknown URI: $uri") | ||||||
|  |         } | ||||||
|  |         context?.contentResolver?.notifyChange(uri, null) | ||||||
|  |         return Uri.parse("${Companion.BASE_URI}/$id") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressWarnings("ConstantConditions") | ||||||
|  |     override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int { | ||||||
|  |         // Not implemented | ||||||
|  |         return 0 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressWarnings("ConstantConditions") | ||||||
|  |     override fun bulkInsert(uri: Uri, values: Array<ContentValues>): Int { | ||||||
|  |         Timber.d("Hello, bulk insert! (CategoryContentProvider)") | ||||||
|  |         val uriType = uriMatcher.match(uri) | ||||||
|  |         val sqlDB = dbOpenHelper.writableDatabase | ||||||
|  |         sqlDB.beginTransaction() | ||||||
|  |         when (uriType) { | ||||||
|  |             CATEGORIES -> { | ||||||
|  |                 for (value in values) { | ||||||
|  |                     Timber.d("Inserting! %s", value) | ||||||
|  |                     sqlDB.insert(TABLE_NAME, null, value) | ||||||
|  |                 } | ||||||
|  |                 sqlDB.setTransactionSuccessful() | ||||||
|  |             } | ||||||
|  |             else -> throw IllegalArgumentException("Unknown URI: $uri") | ||||||
|  |         } | ||||||
|  |         sqlDB.endTransaction() | ||||||
|  |         context?.contentResolver?.notifyChange(uri, null) | ||||||
|  |         return values.size | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressWarnings("ConstantConditions") | ||||||
|  |     override fun update(uri: Uri, contentValues: ContentValues?, selection: String?, | ||||||
|  |                         selectionArgs: Array<String>?): Int { | ||||||
|  |         val uriType = uriMatcher.match(uri) | ||||||
|  |         val sqlDB = dbOpenHelper.writableDatabase | ||||||
|  |         val rowsUpdated: Int | ||||||
|  |         when (uriType) { | ||||||
|  |             CATEGORIES_ID -> { | ||||||
|  |                 if (TextUtils.isEmpty(selection)) { | ||||||
|  |                     val id = uri.lastPathSegment?.toInt() | ||||||
|  |                         ?: throw IllegalArgumentException("Invalid ID") | ||||||
|  |                     rowsUpdated = sqlDB.update(TABLE_NAME, | ||||||
|  |                         contentValues, | ||||||
|  |                         "$COLUMN_ID = ?", | ||||||
|  |                         arrayOf(id.toString())) | ||||||
|  |                 } else { | ||||||
|  |                     throw IllegalArgumentException( | ||||||
|  |                         "Parameter `selection` should be empty when updating an ID") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType") | ||||||
|  |         } | ||||||
|  |         context?.contentResolver?.notifyChange(uri, null) | ||||||
|  |         return rowsUpdated | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         const val TABLE_NAME = "categories" | ||||||
|  | 
 | ||||||
|  |         const val COLUMN_ID = "_id" | ||||||
|  |         const val COLUMN_NAME = "name" | ||||||
|  |         const val COLUMN_DESCRIPTION = "description" | ||||||
|  |         const val COLUMN_THUMBNAIL = "thumbnail" | ||||||
|  |         const val COLUMN_LAST_USED = "last_used" | ||||||
|  |         const val COLUMN_TIMES_USED = "times_used" | ||||||
|  | 
 | ||||||
|  |         // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. | ||||||
|  |         val ALL_FIELDS = arrayOf( | ||||||
|  |             COLUMN_ID, | ||||||
|  |             COLUMN_NAME, | ||||||
|  |             COLUMN_DESCRIPTION, | ||||||
|  |             COLUMN_THUMBNAIL, | ||||||
|  |             COLUMN_LAST_USED, | ||||||
|  |             COLUMN_TIMES_USED | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" | ||||||
|  | 
 | ||||||
|  |         const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + | ||||||
|  |                 "$COLUMN_ID INTEGER PRIMARY KEY," + | ||||||
|  |                 "$COLUMN_NAME TEXT," + | ||||||
|  |                 "$COLUMN_DESCRIPTION TEXT," + | ||||||
|  |                 "$COLUMN_THUMBNAIL TEXT," + | ||||||
|  |                 "$COLUMN_LAST_USED INTEGER," + | ||||||
|  |                 "$COLUMN_TIMES_USED INTEGER" + | ||||||
|  |                 ");" | ||||||
|  | 
 | ||||||
|  |         fun uriForId(id: Int): Uri { | ||||||
|  |             return Uri.parse("${BASE_URI}/$id") | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         fun onCreate(db: SQLiteDatabase) { | ||||||
|  |             db.execSQL(CREATE_TABLE_STATEMENT) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         fun onDelete(db: SQLiteDatabase) { | ||||||
|  |             db.execSQL(DROP_TABLE_STATEMENT) | ||||||
|  |             onCreate(db) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { | ||||||
|  |             if (from == to) return | ||||||
|  |             if (from < 4) { | ||||||
|  |                 // doesn't exist yet | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 4) { | ||||||
|  |                 // table added in version 5 | ||||||
|  |                 onCreate(db) | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 5) { | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 17) { | ||||||
|  |                 db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;") | ||||||
|  |                 db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;") | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // For URI matcher | ||||||
|  |         private const val CATEGORIES = 1 | ||||||
|  |         private const val CATEGORIES_ID = 2 | ||||||
|  |         private const val BASE_PATH = "categories" | ||||||
|  |         val  BASE_URI: Uri = Uri.parse("content://${BuildConfig.CATEGORY_AUTHORITY}/${Companion.BASE_PATH}") | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,207 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| import android.content.ContentProviderClient; |  | ||||||
| import android.content.ContentValues; |  | ||||||
| import android.database.Cursor; |  | ||||||
| import android.database.sqlite.SQLiteDatabase; |  | ||||||
| import android.os.RemoteException; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| 
 |  | ||||||
| import java.util.ArrayList; |  | ||||||
| import java.util.Date; |  | ||||||
| import java.util.List; |  | ||||||
| 
 |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import javax.inject.Provider; |  | ||||||
| 
 |  | ||||||
| public class CategoryDao { |  | ||||||
| 
 |  | ||||||
|     private final Provider<ContentProviderClient> clientProvider; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     public CategoryDao(@Named("category") Provider<ContentProviderClient> clientProvider) { |  | ||||||
|         this.clientProvider = clientProvider; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void save(Category category) { |  | ||||||
|         ContentProviderClient db = clientProvider.get(); |  | ||||||
|         try { |  | ||||||
|             if (category.getContentUri() == null) { |  | ||||||
|                 category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category))); |  | ||||||
|             } else { |  | ||||||
|                 db.update(category.getContentUri(), toContentValues(category), null, null); |  | ||||||
|             } |  | ||||||
|         } catch (RemoteException e) { |  | ||||||
|             throw new RuntimeException(e); |  | ||||||
|         } finally { |  | ||||||
|             db.release(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Find persisted category in database, based on its name. |  | ||||||
|      * |  | ||||||
|      * @param name Category's name |  | ||||||
|      * @return category from database, or null if not found |  | ||||||
|      */ |  | ||||||
|     @Nullable |  | ||||||
|     Category find(String name) { |  | ||||||
|         Cursor cursor = null; |  | ||||||
|         ContentProviderClient db = clientProvider.get(); |  | ||||||
|         try { |  | ||||||
|             cursor = db.query( |  | ||||||
|                     CategoryContentProvider.BASE_URI, |  | ||||||
|                     Table.ALL_FIELDS, |  | ||||||
|                     Table.COLUMN_NAME + "=?", |  | ||||||
|                     new String[]{name}, |  | ||||||
|                     null); |  | ||||||
|             if (cursor != null && cursor.moveToFirst()) { |  | ||||||
|                 return fromCursor(cursor); |  | ||||||
|             } |  | ||||||
|         } catch (RemoteException e) { |  | ||||||
|             // This feels lazy, but to hell with checked exceptions. :) |  | ||||||
|             throw new RuntimeException(e); |  | ||||||
|         } finally { |  | ||||||
|             if (cursor != null) { |  | ||||||
|                 cursor.close(); |  | ||||||
|             } |  | ||||||
|             db.release(); |  | ||||||
|         } |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Retrieve recently-used categories, ordered by descending date. |  | ||||||
|      * |  | ||||||
|      * @return a list containing recent categories |  | ||||||
|      */ |  | ||||||
|     @NonNull |  | ||||||
|     List<CategoryItem> recentCategories(int limit) { |  | ||||||
|         List<CategoryItem> items = new ArrayList<>(); |  | ||||||
|         Cursor cursor = null; |  | ||||||
|         ContentProviderClient db = clientProvider.get(); |  | ||||||
|         try { |  | ||||||
|             cursor = db.query( |  | ||||||
|                     CategoryContentProvider.BASE_URI, |  | ||||||
|                     Table.ALL_FIELDS, |  | ||||||
|                     null, |  | ||||||
|                     new String[]{}, |  | ||||||
|                     Table.COLUMN_LAST_USED + " DESC"); |  | ||||||
|             // fixme add a limit on the original query instead of falling out of the loop? |  | ||||||
|             while (cursor != null && cursor.moveToNext() |  | ||||||
|                     && cursor.getPosition() < limit) { |  | ||||||
|                 if (fromCursor(cursor).getName() != null ) { |  | ||||||
|                     items.add(new CategoryItem(fromCursor(cursor).getName(), |  | ||||||
|                         fromCursor(cursor).getDescription(), fromCursor(cursor).getThumbnail(), |  | ||||||
|                         false)); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } catch (RemoteException e) { |  | ||||||
|             throw new RuntimeException(e); |  | ||||||
|         } finally { |  | ||||||
|             if (cursor != null) { |  | ||||||
|                 cursor.close(); |  | ||||||
|             } |  | ||||||
|             db.release(); |  | ||||||
|         } |  | ||||||
|         return items; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @NonNull |  | ||||||
|     Category fromCursor(Cursor cursor) { |  | ||||||
|         // Hardcoding column positions! |  | ||||||
|         return new Category( |  | ||||||
|                 CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))), |  | ||||||
|                 cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)), |  | ||||||
|                 cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)), |  | ||||||
|                 cursor.getString(cursor.getColumnIndex(Table.COLUMN_THUMBNAIL)), |  | ||||||
|                 new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))), |  | ||||||
|                 cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED)) |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private ContentValues toContentValues(Category category) { |  | ||||||
|         ContentValues cv = new ContentValues(); |  | ||||||
|         cv.put(CategoryDao.Table.COLUMN_NAME, category.getName()); |  | ||||||
|         cv.put(Table.COLUMN_DESCRIPTION, category.getDescription()); |  | ||||||
|         cv.put(Table.COLUMN_THUMBNAIL, category.getThumbnail()); |  | ||||||
|         cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime()); |  | ||||||
|         cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed()); |  | ||||||
|         return cv; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public static class Table { |  | ||||||
|         public static final String TABLE_NAME = "categories"; |  | ||||||
| 
 |  | ||||||
|         public static final String COLUMN_ID = "_id"; |  | ||||||
|         static final String COLUMN_NAME = "name"; |  | ||||||
|         static final String COLUMN_DESCRIPTION = "description"; |  | ||||||
|         static final String COLUMN_THUMBNAIL = "thumbnail"; |  | ||||||
|         static final String COLUMN_LAST_USED = "last_used"; |  | ||||||
|         static final String COLUMN_TIMES_USED = "times_used"; |  | ||||||
| 
 |  | ||||||
|         // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. |  | ||||||
|         public static final String[] ALL_FIELDS = { |  | ||||||
|                 COLUMN_ID, |  | ||||||
|                 COLUMN_NAME, |  | ||||||
|                 COLUMN_DESCRIPTION, |  | ||||||
|                 COLUMN_THUMBNAIL, |  | ||||||
|                 COLUMN_LAST_USED, |  | ||||||
|                 COLUMN_TIMES_USED |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME; |  | ||||||
| 
 |  | ||||||
|         static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" |  | ||||||
|                 + COLUMN_ID + " INTEGER PRIMARY KEY," |  | ||||||
|                 + COLUMN_NAME + " STRING," |  | ||||||
|                 + COLUMN_DESCRIPTION + " STRING," |  | ||||||
|                 + COLUMN_THUMBNAIL + " STRING," |  | ||||||
|                 + COLUMN_LAST_USED + " INTEGER," |  | ||||||
|                 + COLUMN_TIMES_USED + " INTEGER" |  | ||||||
|                 + ");"; |  | ||||||
| 
 |  | ||||||
|         public static void onCreate(SQLiteDatabase db) { |  | ||||||
|             db.execSQL(CREATE_TABLE_STATEMENT); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         public static void onDelete(SQLiteDatabase db) { |  | ||||||
|             db.execSQL(DROP_TABLE_STATEMENT); |  | ||||||
|             onCreate(db); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         public static void onUpdate(SQLiteDatabase db, int from, int to) { |  | ||||||
|             if (from == to) { |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             if (from < 4) { |  | ||||||
|                 // doesn't exist yet |  | ||||||
|                 from++; |  | ||||||
|                 onUpdate(db, from, to); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             if (from == 4) { |  | ||||||
|                 // table added in version 5 |  | ||||||
|                 onCreate(db); |  | ||||||
|                 from++; |  | ||||||
|                 onUpdate(db, from, to); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             if (from == 5) { |  | ||||||
|                 from++; |  | ||||||
|                 onUpdate(db, from, to); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             if (from == 17) { |  | ||||||
|                 db.execSQL("ALTER TABLE categories ADD COLUMN description STRING;"); |  | ||||||
|                 db.execSQL("ALTER TABLE categories ADD COLUMN thumbnail STRING;"); |  | ||||||
|                 from++; |  | ||||||
|                 onUpdate(db, from, to); |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										194
									
								
								app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,194 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | import android.annotation.SuppressLint | ||||||
|  | import android.content.ContentProviderClient | ||||||
|  | import android.content.ContentValues | ||||||
|  | import android.database.Cursor | ||||||
|  | import android.database.sqlite.SQLiteDatabase | ||||||
|  | import android.os.RemoteException | ||||||
|  | 
 | ||||||
|  | import java.util.ArrayList | ||||||
|  | import java.util.Date | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | import javax.inject.Provider | ||||||
|  | 
 | ||||||
|  | class CategoryDao @Inject constructor( | ||||||
|  |     @Named("category") private val clientProvider: Provider<ContentProviderClient> | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
|  |     fun save(category: Category) { | ||||||
|  |         val db = clientProvider.get() | ||||||
|  |         try { | ||||||
|  |             if (category.contentUri == null) { | ||||||
|  |                 category.contentUri = db.insert( | ||||||
|  |                     CategoryContentProvider.BASE_URI, | ||||||
|  |                     toContentValues(category) | ||||||
|  |                 ) | ||||||
|  |             } else { | ||||||
|  |                 db.update( | ||||||
|  |                     category.contentUri!!, | ||||||
|  |                     toContentValues(category), | ||||||
|  |                     null, | ||||||
|  |                     null | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } catch (e: RemoteException) { | ||||||
|  |             throw RuntimeException(e) | ||||||
|  |         } finally { | ||||||
|  |             db.release() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Find persisted category in database, based on its name. | ||||||
|  |      * | ||||||
|  |      * @param name Category's name | ||||||
|  |      * @return category from database, or null if not found | ||||||
|  |      */ | ||||||
|  |     fun find(name: String): Category? { | ||||||
|  |         var cursor: Cursor? = null | ||||||
|  |         val db = clientProvider.get() | ||||||
|  |         try { | ||||||
|  |             cursor = db.query( | ||||||
|  |                 CategoryContentProvider.BASE_URI, | ||||||
|  |                 ALL_FIELDS, | ||||||
|  |                 "${COLUMN_NAME}=?", | ||||||
|  |                 arrayOf(name), | ||||||
|  |                 null | ||||||
|  |             ) | ||||||
|  |             if (cursor != null && cursor.moveToFirst()) { | ||||||
|  |                 return fromCursor(cursor) | ||||||
|  |             } | ||||||
|  |         } catch (e: RemoteException) { | ||||||
|  |             throw RuntimeException(e) | ||||||
|  |         } finally { | ||||||
|  |             cursor?.close() | ||||||
|  |             db.release() | ||||||
|  |         } | ||||||
|  |         return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Retrieve recently-used categories, ordered by descending date. | ||||||
|  |      * | ||||||
|  |      * @return a list containing recent categories | ||||||
|  |      */ | ||||||
|  |     fun recentCategories(limit: Int): List<CategoryItem> { | ||||||
|  |         val items = ArrayList<CategoryItem>() | ||||||
|  |         var cursor: Cursor? = null | ||||||
|  |         val db = clientProvider.get() | ||||||
|  |         try { | ||||||
|  |             cursor = db.query( | ||||||
|  |                 CategoryContentProvider.BASE_URI, | ||||||
|  |                 ALL_FIELDS, | ||||||
|  |                 null, | ||||||
|  |                 emptyArray(), | ||||||
|  |                 "$COLUMN_LAST_USED DESC" | ||||||
|  |             ) | ||||||
|  |             while (cursor != null && cursor.moveToNext() && cursor.position < limit) { | ||||||
|  |                 val category = fromCursor(cursor) | ||||||
|  |                 if (category.name != null) { | ||||||
|  |                     items.add( | ||||||
|  |                         CategoryItem( | ||||||
|  |                             category.name, | ||||||
|  |                             category.description, | ||||||
|  |                             category.thumbnail, | ||||||
|  |                             false | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } catch (e: RemoteException) { | ||||||
|  |             throw RuntimeException(e) | ||||||
|  |         } finally { | ||||||
|  |             cursor?.close() | ||||||
|  |             db.release() | ||||||
|  |         } | ||||||
|  |         return items | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressLint("Range") | ||||||
|  |     fun fromCursor(cursor: Cursor): Category { | ||||||
|  |         // Hardcoding column positions! | ||||||
|  |         return Category( | ||||||
|  |             CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))), | ||||||
|  |             cursor.getString(cursor.getColumnIndex(COLUMN_NAME)), | ||||||
|  |             cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)), | ||||||
|  |             cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)), | ||||||
|  |             Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))), | ||||||
|  |             cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED)) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun toContentValues(category: Category): ContentValues { | ||||||
|  |         return ContentValues().apply { | ||||||
|  |             put(COLUMN_NAME, category.name) | ||||||
|  |             put(COLUMN_DESCRIPTION, category.description) | ||||||
|  |             put(COLUMN_THUMBNAIL, category.thumbnail) | ||||||
|  |             put(COLUMN_LAST_USED, category.lastUsed?.time) | ||||||
|  |             put(COLUMN_TIMES_USED, category.timesUsed) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object Table { | ||||||
|  |         const val TABLE_NAME = "categories" | ||||||
|  | 
 | ||||||
|  |         const val COLUMN_ID = "_id" | ||||||
|  |         const val COLUMN_NAME = "name" | ||||||
|  |         const val COLUMN_DESCRIPTION = "description" | ||||||
|  |         const val COLUMN_THUMBNAIL = "thumbnail" | ||||||
|  |         const val COLUMN_LAST_USED = "last_used" | ||||||
|  |         const val COLUMN_TIMES_USED = "times_used" | ||||||
|  | 
 | ||||||
|  |         // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. | ||||||
|  |         val ALL_FIELDS = arrayOf( | ||||||
|  |             COLUMN_ID, | ||||||
|  |             COLUMN_NAME, | ||||||
|  |             COLUMN_DESCRIPTION, | ||||||
|  |             COLUMN_THUMBNAIL, | ||||||
|  |             COLUMN_LAST_USED, | ||||||
|  |             COLUMN_TIMES_USED | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" | ||||||
|  | 
 | ||||||
|  |         const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + | ||||||
|  |                 "$COLUMN_ID INTEGER PRIMARY KEY," + | ||||||
|  |                 "$COLUMN_NAME STRING," + | ||||||
|  |                 "$COLUMN_DESCRIPTION STRING," + | ||||||
|  |                 "$COLUMN_THUMBNAIL STRING," + | ||||||
|  |                 "$COLUMN_LAST_USED INTEGER," + | ||||||
|  |                 "$COLUMN_TIMES_USED INTEGER" + | ||||||
|  |                 ");" | ||||||
|  | 
 | ||||||
|  |         @SuppressLint("SQLiteString") | ||||||
|  |         fun onCreate(db: SQLiteDatabase) { | ||||||
|  |             db.execSQL(CREATE_TABLE_STATEMENT) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         fun onDelete(db: SQLiteDatabase) { | ||||||
|  |             db.execSQL(DROP_TABLE_STATEMENT) | ||||||
|  |             onCreate(db) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @SuppressLint("SQLiteString") | ||||||
|  |         fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { | ||||||
|  |             if (from == to) return | ||||||
|  |             if (from < 4) { | ||||||
|  |                 // doesn't exist yet | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 4) { | ||||||
|  |                 // table added in version 5 | ||||||
|  |                 onCreate(db) | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 5) { | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } else if (from == 17) { | ||||||
|  |                 db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;") | ||||||
|  |                 db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;") | ||||||
|  |                 onUpdate(db, from + 1, to) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,236 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX; |  | ||||||
| 
 |  | ||||||
| import android.content.Context; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.net.Uri; |  | ||||||
| import android.os.Bundle; |  | ||||||
| import android.view.Menu; |  | ||||||
| import android.view.MenuInflater; |  | ||||||
| import android.view.MenuItem; |  | ||||||
| import android.view.View; |  | ||||||
| import android.widget.FrameLayout; |  | ||||||
| import androidx.appcompat.widget.Toolbar; |  | ||||||
| import androidx.fragment.app.Fragment; |  | ||||||
| import androidx.fragment.app.FragmentManager; |  | ||||||
| import androidx.viewpager.widget.ViewPager; |  | ||||||
| import com.google.android.material.tabs.TabLayout; |  | ||||||
| import fr.free.nrw.commons.Media; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.Utils; |  | ||||||
| import fr.free.nrw.commons.ViewPagerAdapter; |  | ||||||
| import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding; |  | ||||||
| import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment; |  | ||||||
| import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment; |  | ||||||
| import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment; |  | ||||||
| import fr.free.nrw.commons.media.MediaDetailPagerFragment; |  | ||||||
| import fr.free.nrw.commons.theme.BaseActivity; |  | ||||||
| import java.util.ArrayList; |  | ||||||
| import java.util.List; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.page.PageTitle; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * This activity displays details of a particular category |  | ||||||
|  * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in |  | ||||||
|  * a particular category on wikimedia commons. |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| public class CategoryDetailsActivity extends BaseActivity |  | ||||||
|         implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback { |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     private FragmentManager supportFragmentManager; |  | ||||||
|     private CategoriesMediaFragment categoriesMediaFragment; |  | ||||||
|     private MediaDetailPagerFragment mediaDetails; |  | ||||||
|     private String categoryName; |  | ||||||
|     ViewPagerAdapter viewPagerAdapter; |  | ||||||
| 
 |  | ||||||
|     private ActivityCategoryDetailsBinding binding; |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     protected void onCreate(Bundle savedInstanceState) { |  | ||||||
|         super.onCreate(savedInstanceState); |  | ||||||
| 
 |  | ||||||
|         binding = ActivityCategoryDetailsBinding.inflate(getLayoutInflater()); |  | ||||||
|         final View view = binding.getRoot(); |  | ||||||
|         setContentView(view); |  | ||||||
|         supportFragmentManager = getSupportFragmentManager(); |  | ||||||
|         viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); |  | ||||||
|         binding.viewPager.setAdapter(viewPagerAdapter); |  | ||||||
|         binding.viewPager.setOffscreenPageLimit(2); |  | ||||||
|         binding.tabLayout.setupWithViewPager(binding.viewPager); |  | ||||||
|         setSupportActionBar(binding.toolbarBinding.toolbar); |  | ||||||
|         getSupportActionBar().setDisplayHomeAsUpEnabled(true); |  | ||||||
|         setTabs(); |  | ||||||
|         setPageTitle(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, |  | ||||||
|      * Set the fragments according to the tab selected in the viewPager. |  | ||||||
|      */ |  | ||||||
|     private void setTabs() { |  | ||||||
|         List<Fragment> fragmentList = new ArrayList<>(); |  | ||||||
|         List<String> titleList = new ArrayList<>(); |  | ||||||
|         categoriesMediaFragment = new CategoriesMediaFragment(); |  | ||||||
|         SubCategoriesFragment subCategoryListFragment = new SubCategoriesFragment(); |  | ||||||
|         ParentCategoriesFragment parentCategoriesFragment = new ParentCategoriesFragment(); |  | ||||||
|         categoryName = getIntent().getStringExtra("categoryName"); |  | ||||||
|         if (getIntent() != null && categoryName != null) { |  | ||||||
|             Bundle arguments = new Bundle(); |  | ||||||
|             arguments.putString("categoryName", categoryName); |  | ||||||
|             categoriesMediaFragment.setArguments(arguments); |  | ||||||
|             subCategoryListFragment.setArguments(arguments); |  | ||||||
|             parentCategoriesFragment.setArguments(arguments); |  | ||||||
|         } |  | ||||||
|         fragmentList.add(categoriesMediaFragment); |  | ||||||
|         titleList.add("MEDIA"); |  | ||||||
|         fragmentList.add(subCategoryListFragment); |  | ||||||
|         titleList.add("SUBCATEGORIES"); |  | ||||||
|         fragmentList.add(parentCategoriesFragment); |  | ||||||
|         titleList.add("PARENT CATEGORIES"); |  | ||||||
|         viewPagerAdapter.setTabData(fragmentList, titleList); |  | ||||||
|         viewPagerAdapter.notifyDataSetChanged(); |  | ||||||
| 
 |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Gets the passed categoryName from the intents and displays it as the page title |  | ||||||
|      */ |  | ||||||
|     private void setPageTitle() { |  | ||||||
|         if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) { |  | ||||||
|             setTitle(getIntent().getStringExtra("categoryName")); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method is called onClick of media inside category details (CategoryImageListFragment). |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onMediaClicked(int position) { |  | ||||||
|         binding.tabLayout.setVisibility(View.GONE); |  | ||||||
|         binding.viewPager.setVisibility(View.GONE); |  | ||||||
|         binding.mediaContainer.setVisibility(View.VISIBLE); |  | ||||||
|         if (mediaDetails == null || !mediaDetails.isVisible()) { |  | ||||||
|             // set isFeaturedImage true for featured images, to include author field on media detail |  | ||||||
|             mediaDetails = MediaDetailPagerFragment.newInstance(false, true); |  | ||||||
|             FragmentManager supportFragmentManager = getSupportFragmentManager(); |  | ||||||
|             supportFragmentManager |  | ||||||
|                     .beginTransaction() |  | ||||||
|                     .replace(R.id.mediaContainer, mediaDetails) |  | ||||||
|                     .addToBackStack(null) |  | ||||||
|                     .commit(); |  | ||||||
|             supportFragmentManager.executePendingTransactions(); |  | ||||||
|         } |  | ||||||
|         mediaDetails.showImage(position); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Consumers should be simply using this method to use this activity. |  | ||||||
|      * @param context  A Context of the application package implementing this class. |  | ||||||
|      * @param categoryName Name of the category for displaying its details |  | ||||||
|      */ |  | ||||||
|     public static void startYourself(Context context, String categoryName) { |  | ||||||
|         Intent intent = new Intent(context, CategoryDetailsActivity.class); |  | ||||||
|         intent.putExtra("categoryName", categoryName); |  | ||||||
|         context.startActivity(intent); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index |  | ||||||
|      * @param i It is the index of which media object is to be returned which is same as |  | ||||||
|      *          current index of viewPager. |  | ||||||
|      * @return Media Object |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public Media getMediaAtPosition(int i) { |  | ||||||
|         return categoriesMediaFragment.getMediaAtPosition(i); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method is called on from getCount of MediaDetailPagerFragment |  | ||||||
|      * The viewpager will contain same number of media items as that of media elements in adapter. |  | ||||||
|      * @return Total Media count in the adapter |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public int getTotalMediaCount() { |  | ||||||
|         return categoriesMediaFragment.getTotalMediaCount(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Integer getContributionStateAt(int position) { |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Reload media detail fragment once media is nominated |  | ||||||
|      * |  | ||||||
|      * @param index item position that has been nominated |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void refreshNominatedMedia(int index) { |  | ||||||
|         if (getSupportFragmentManager().getBackStackEntryCount() == 1) { |  | ||||||
|             onBackPressed(); |  | ||||||
|             onMediaClicked(index); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method inflates the menu in the toolbar |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public boolean onCreateOptionsMenu(Menu menu) { |  | ||||||
|         MenuInflater inflater = getMenuInflater(); |  | ||||||
|         inflater.inflate(R.menu.fragment_category_detail, menu); |  | ||||||
|         return super.onCreateOptionsMenu(menu); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method handles the logic on ItemSelect in toolbar menu |  | ||||||
|      * Currently only 1 choice is available to open category details page in browser |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public boolean onOptionsItemSelected(MenuItem item) { |  | ||||||
| 
 |  | ||||||
|         // Handle item selection |  | ||||||
|         switch (item.getItemId()) { |  | ||||||
|             case R.id.menu_browser_current_category: |  | ||||||
|                 PageTitle title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName); |  | ||||||
|                 Utils.handleWebUrl(this, Uri.parse(title.getCanonicalUri())); |  | ||||||
|                 return true; |  | ||||||
|             case android.R.id.home: |  | ||||||
|                 onBackPressed(); |  | ||||||
|                 return true; |  | ||||||
|             default: |  | ||||||
|                 return super.onOptionsItemSelected(item); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method is called on backPressed of anyFragment in the activity. |  | ||||||
|      * If condition is called when mediaDetailFragment is opened. |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onBackPressed() { |  | ||||||
|         if (supportFragmentManager.getBackStackEntryCount() == 1){ |  | ||||||
|             binding.tabLayout.setVisibility(View.VISIBLE); |  | ||||||
|             binding.viewPager.setVisibility(View.VISIBLE); |  | ||||||
|             binding.mediaContainer.setVisibility(View.GONE); |  | ||||||
|         } |  | ||||||
|         super.onBackPressed(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * This method is called on success of API call for Images inside a category. |  | ||||||
|      * The viewpager will notified that number of items have changed. |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void viewPagerNotifyDataSetChanged() { |  | ||||||
|         if (mediaDetails!=null){ |  | ||||||
|             mediaDetails.notifyDataSetChanged(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,262 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | import android.content.Context | ||||||
|  | import android.content.Intent | ||||||
|  | import android.net.Uri | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.view.Menu | ||||||
|  | import android.view.MenuItem | ||||||
|  | import android.view.View | ||||||
|  | import androidx.activity.viewModels | ||||||
|  | import androidx.fragment.app.Fragment | ||||||
|  | import androidx.fragment.app.FragmentManager | ||||||
|  | import androidx.lifecycle.Lifecycle | ||||||
|  | import androidx.lifecycle.lifecycleScope | ||||||
|  | import androidx.lifecycle.repeatOnLifecycle | ||||||
|  | import fr.free.nrw.commons.Media | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.Utils | ||||||
|  | import fr.free.nrw.commons.ViewPagerAdapter | ||||||
|  | import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding | ||||||
|  | import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment | ||||||
|  | import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment | ||||||
|  | import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment | ||||||
|  | import fr.free.nrw.commons.media.MediaDetailPagerFragment | ||||||
|  | import fr.free.nrw.commons.theme.BaseActivity | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * This activity displays details of a particular category | ||||||
|  |  * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in | ||||||
|  |  * a particular category on wikimedia commons. | ||||||
|  |  */ | ||||||
|  | class CategoryDetailsActivity : BaseActivity(), | ||||||
|  |     MediaDetailPagerFragment.MediaDetailProvider, | ||||||
|  |     CategoryImagesCallback { | ||||||
|  | 
 | ||||||
|  |     private lateinit var supportFragmentManager: FragmentManager | ||||||
|  |     private lateinit var categoriesMediaFragment: CategoriesMediaFragment | ||||||
|  |     private var mediaDetails: MediaDetailPagerFragment? = null | ||||||
|  |     private var categoryName: String? = null | ||||||
|  |     private lateinit var viewPagerAdapter: ViewPagerAdapter | ||||||
|  | 
 | ||||||
|  |     private lateinit var binding: ActivityCategoryDetailsBinding | ||||||
|  | 
 | ||||||
|  |     @Inject | ||||||
|  |     lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory | ||||||
|  | 
 | ||||||
|  |     private val viewModel: CategoryDetailsViewModel by viewModels<CategoryDetailsViewModel> { categoryViewModelFactory } | ||||||
|  | 
 | ||||||
|  |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  | 
 | ||||||
|  |         binding = ActivityCategoryDetailsBinding.inflate(layoutInflater) | ||||||
|  |         val view = binding.root | ||||||
|  |         setContentView(view) | ||||||
|  |         supportFragmentManager = getSupportFragmentManager() | ||||||
|  |         viewPagerAdapter = ViewPagerAdapter(supportFragmentManager) | ||||||
|  |         binding.viewPager.adapter = viewPagerAdapter | ||||||
|  |         binding.viewPager.offscreenPageLimit = 2 | ||||||
|  |         binding.tabLayout.setupWithViewPager(binding.viewPager) | ||||||
|  |         setSupportActionBar(binding.toolbarBinding.toolbar) | ||||||
|  |         supportActionBar?.setDisplayHomeAsUpEnabled(true) | ||||||
|  |         setTabs() | ||||||
|  |         setPageTitle() | ||||||
|  | 
 | ||||||
|  |         lifecycleScope.launch { | ||||||
|  |             repeatOnLifecycle(Lifecycle.State.STARTED){ | ||||||
|  |                 viewModel.bookmarkState.collect { | ||||||
|  |                     invalidateOptionsMenu() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, | ||||||
|  |      * Set the fragments according to the tab selected in the viewPager. | ||||||
|  |      */ | ||||||
|  |     private fun setTabs() { | ||||||
|  |         val fragmentList = mutableListOf<Fragment>() | ||||||
|  |         val titleList = mutableListOf<String>() | ||||||
|  |         categoriesMediaFragment = CategoriesMediaFragment() | ||||||
|  |         val subCategoryListFragment = SubCategoriesFragment() | ||||||
|  |         val parentCategoriesFragment = ParentCategoriesFragment() | ||||||
|  |         categoryName = intent?.getStringExtra("categoryName") | ||||||
|  |         if (intent != null && categoryName != null) { | ||||||
|  |             val arguments = Bundle().apply { | ||||||
|  |                 putString("categoryName", categoryName) | ||||||
|  |             } | ||||||
|  |             categoriesMediaFragment.arguments = arguments | ||||||
|  |             subCategoryListFragment.arguments = arguments | ||||||
|  |             parentCategoriesFragment.arguments = arguments | ||||||
|  | 
 | ||||||
|  |             viewModel.onCheckIfBookmarked(categoryName!!) | ||||||
|  |         } | ||||||
|  |         fragmentList.add(categoriesMediaFragment) | ||||||
|  |         titleList.add("MEDIA") | ||||||
|  |         fragmentList.add(subCategoryListFragment) | ||||||
|  |         titleList.add("SUBCATEGORIES") | ||||||
|  |         fragmentList.add(parentCategoriesFragment) | ||||||
|  |         titleList.add("PARENT CATEGORIES") | ||||||
|  |         viewPagerAdapter.setTabData(fragmentList, titleList) | ||||||
|  |         viewPagerAdapter.notifyDataSetChanged() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Gets the passed categoryName from the intents and displays it as the page title | ||||||
|  |      */ | ||||||
|  |     private fun setPageTitle() { | ||||||
|  |         intent?.getStringExtra("categoryName")?.let { | ||||||
|  |             title = it | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method is called onClick of media inside category details (CategoryImageListFragment). | ||||||
|  |      */ | ||||||
|  |     override fun onMediaClicked(position: Int) { | ||||||
|  |         binding.tabLayout.visibility = View.GONE | ||||||
|  |         binding.viewPager.visibility = View.GONE | ||||||
|  |         binding.mediaContainer.visibility = View.VISIBLE | ||||||
|  |         if (mediaDetails == null || mediaDetails?.isVisible == false) { | ||||||
|  |             // set isFeaturedImage true for featured images, to include author field on media detail | ||||||
|  |             mediaDetails = MediaDetailPagerFragment.newInstance(false, true) | ||||||
|  |             supportFragmentManager.beginTransaction() | ||||||
|  |                 .replace(R.id.mediaContainer, mediaDetails!!) | ||||||
|  |                 .addToBackStack(null) | ||||||
|  |                 .commit() | ||||||
|  |             supportFragmentManager.executePendingTransactions() | ||||||
|  |         } | ||||||
|  |         mediaDetails?.showImage(position) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         /** | ||||||
|  |          * Consumers should be simply using this method to use this activity. | ||||||
|  |          * @param context  A Context of the application package implementing this class. | ||||||
|  |          * @param categoryName Name of the category for displaying its details | ||||||
|  |          */ | ||||||
|  |         fun startYourself(context: Context?, categoryName: String) { | ||||||
|  |             val intent = Intent(context, CategoryDetailsActivity::class.java).apply { | ||||||
|  |                 putExtra("categoryName", categoryName) | ||||||
|  |             } | ||||||
|  |             context?.startActivity(intent) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index | ||||||
|  |      * @param i It is the index of which media object is to be returned which is same as | ||||||
|  |      *          current index of viewPager. | ||||||
|  |      * @return Media Object | ||||||
|  |      */ | ||||||
|  |     override fun getMediaAtPosition(i: Int): Media? { | ||||||
|  |         return categoriesMediaFragment.getMediaAtPosition(i) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method is called on from getCount of MediaDetailPagerFragment | ||||||
|  |      * The viewpager will contain same number of media items as that of media elements in adapter. | ||||||
|  |      * @return Total Media count in the adapter | ||||||
|  |      */ | ||||||
|  |     override fun getTotalMediaCount(): Int { | ||||||
|  |         return categoriesMediaFragment.getTotalMediaCount() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getContributionStateAt(position: Int): Int? { | ||||||
|  |         return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Reload media detail fragment once media is nominated | ||||||
|  |      * | ||||||
|  |      * @param index item position that has been nominated | ||||||
|  |      */ | ||||||
|  |     override fun refreshNominatedMedia(index: Int) { | ||||||
|  |         if (supportFragmentManager.backStackEntryCount == 1) { | ||||||
|  |             onBackPressed() | ||||||
|  |             onMediaClicked(index) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method inflates the menu in the toolbar | ||||||
|  |      */ | ||||||
|  |     override fun onCreateOptionsMenu(menu: Menu?): Boolean { | ||||||
|  |         menuInflater.inflate(R.menu.fragment_category_detail, menu) | ||||||
|  |         return super.onCreateOptionsMenu(menu) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method handles the logic on ItemSelect in toolbar menu | ||||||
|  |      * Currently only 1 choice is available to open category details page in browser | ||||||
|  |      */ | ||||||
|  |     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||||
|  |         return when (item.itemId) { | ||||||
|  |             R.id.menu_browser_current_category -> { | ||||||
|  |                 val title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName) | ||||||
|  |                 Utils.handleWebUrl(this, Uri.parse(title.canonicalUri)) | ||||||
|  |                 true | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             R.id.menu_bookmark_current_category -> { | ||||||
|  |                 categoryName?.let { | ||||||
|  |                     viewModel.onBookmarkClick(categoryName = it) | ||||||
|  |                 } | ||||||
|  |                 true | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             android.R.id.home -> { | ||||||
|  |                 onBackPressed() | ||||||
|  |                 true | ||||||
|  |             } | ||||||
|  |             else -> super.onOptionsItemSelected(item) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun onPrepareOptionsMenu(menu: Menu?): Boolean { | ||||||
|  |         menu?.run { | ||||||
|  |           val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category) | ||||||
|  |             if (bookmarkMenuItem != null) { | ||||||
|  |                 val icon = if(viewModel.bookmarkState.value){ | ||||||
|  |                     R.drawable.menu_ic_round_star_filled_24px | ||||||
|  |                 } else { | ||||||
|  |                     R.drawable.menu_ic_round_star_border_24px | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 bookmarkMenuItem.setIcon(icon) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return super.onPrepareOptionsMenu(menu) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method is called on backPressed of anyFragment in the activity. | ||||||
|  |      * If condition is called when mediaDetailFragment is opened. | ||||||
|  |      */ | ||||||
|  |     @Deprecated("This method has been deprecated in favor of using the" + | ||||||
|  |             "{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." + | ||||||
|  |             "The OnBackPressedDispatcher controls how back button events are dispatched" + | ||||||
|  |             "to one or more {@link OnBackPressedCallback} objects.") | ||||||
|  |     override fun onBackPressed() { | ||||||
|  |         if (supportFragmentManager.backStackEntryCount == 1) { | ||||||
|  |             binding.tabLayout.visibility = View.VISIBLE | ||||||
|  |             binding.viewPager.visibility = View.VISIBLE | ||||||
|  |             binding.mediaContainer.visibility = View.GONE | ||||||
|  |         } | ||||||
|  |         super.onBackPressed() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * This method is called on success of API call for Images inside a category. | ||||||
|  |      * The viewpager will notified that number of items have changed. | ||||||
|  |      */ | ||||||
|  |     override fun viewPagerNotifyDataSetChanged() { | ||||||
|  |         mediaDetails?.notifyDataSetChanged() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,109 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import androidx.lifecycle.ViewModelProvider | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao | ||||||
|  | import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.asStateFlow | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * ViewModal for [CategoryDetailsActivity] | ||||||
|  |  */ | ||||||
|  | class CategoryDetailsViewModel( | ||||||
|  |     private val bookmarkCategoriesDao: BookmarkCategoriesDao | ||||||
|  | ) : ViewModel() { | ||||||
|  | 
 | ||||||
|  |     private val _bookmarkState = MutableStateFlow(false) | ||||||
|  |     val bookmarkState = _bookmarkState.asStateFlow() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Used to check if bookmark exists for the given category in DB | ||||||
|  |      * based on that bookmark state is updated | ||||||
|  |      * @param categoryName | ||||||
|  |      */ | ||||||
|  |     fun onCheckIfBookmarked(categoryName: String) { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName) | ||||||
|  |             _bookmarkState.update { | ||||||
|  |                 isBookmarked | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Handles event when bookmark button is clicked from view | ||||||
|  |      * based on that category is bookmarked or removed in/from in the DB | ||||||
|  |      * and bookmark state is update as well | ||||||
|  |      * @param categoryName | ||||||
|  |      */ | ||||||
|  |     fun onBookmarkClick(categoryName: String) { | ||||||
|  |         if (_bookmarkState.value) { | ||||||
|  |             deleteBookmark(categoryName) | ||||||
|  |             _bookmarkState.update { | ||||||
|  |                 false | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             addBookmark(categoryName) | ||||||
|  |             _bookmarkState.update { | ||||||
|  |                 true | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Add bookmark into DB | ||||||
|  |      * | ||||||
|  |      * @param categoryName | ||||||
|  |      */ | ||||||
|  |     private fun addBookmark(categoryName: String) { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             val categoryItem = BookmarksCategoryModal( | ||||||
|  |                 categoryName = categoryName | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             bookmarkCategoriesDao.insert(categoryItem) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Delete bookmark from DB | ||||||
|  |      * | ||||||
|  |      * @param categoryName | ||||||
|  |      */ | ||||||
|  |     private fun deleteBookmark(categoryName: String) { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             bookmarkCategoriesDao.delete( | ||||||
|  |                 BookmarksCategoryModal( | ||||||
|  |                     categoryName = categoryName | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * View model factory to create [CategoryDetailsViewModel] | ||||||
|  |      * | ||||||
|  |      * @property bookmarkCategoriesDao | ||||||
|  |      * @constructor Create empty View model factory | ||||||
|  |      */ | ||||||
|  |     class ViewModelFactory @Inject constructor( | ||||||
|  |         private val bookmarkCategoriesDao: BookmarkCategoriesDao | ||||||
|  |     ) : ViewModelProvider.Factory { | ||||||
|  | 
 | ||||||
|  |         @Suppress("UNCHECKED_CAST") | ||||||
|  |         override fun <T : ViewModel> create(modelClass: Class<T>): T = | ||||||
|  |             if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) { | ||||||
|  |                 CategoryDetailsViewModel(bookmarkCategoriesDao) as T | ||||||
|  |             } else { | ||||||
|  |                 throw IllegalArgumentException("Unknown class name") | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,123 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_CATEGORY; |  | ||||||
| 
 |  | ||||||
| import android.content.Context; |  | ||||||
| import android.content.Intent; |  | ||||||
| import android.net.Uri; |  | ||||||
| import fr.free.nrw.commons.BuildConfig; |  | ||||||
| import fr.free.nrw.commons.Media; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.actions.PageEditClient; |  | ||||||
| import fr.free.nrw.commons.notification.NotificationHelper; |  | ||||||
| import fr.free.nrw.commons.utils.ViewUtilWrapper; |  | ||||||
| import io.reactivex.Observable; |  | ||||||
| import io.reactivex.Single; |  | ||||||
| import java.util.List; |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| public class CategoryEditHelper { |  | ||||||
|     private final NotificationHelper notificationHelper; |  | ||||||
|     public final PageEditClient pageEditClient; |  | ||||||
|     private final ViewUtilWrapper viewUtil; |  | ||||||
|     private final String username; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     public CategoryEditHelper(NotificationHelper notificationHelper, |  | ||||||
|         @Named("commons-page-edit") PageEditClient pageEditClient, |  | ||||||
|         ViewUtilWrapper viewUtil, |  | ||||||
|         @Named("username") String username) { |  | ||||||
|         this.notificationHelper = notificationHelper; |  | ||||||
|         this.pageEditClient = pageEditClient; |  | ||||||
|         this.viewUtil = viewUtil; |  | ||||||
|         this.username = username; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Public interface to edit categories |  | ||||||
|      * @param context |  | ||||||
|      * @param media |  | ||||||
|      * @param categories |  | ||||||
|      * @return |  | ||||||
|      */ |  | ||||||
|     public Single<Boolean> makeCategoryEdit(Context context, Media media, List<String> categories, |  | ||||||
|         final String wikiText) { |  | ||||||
|         viewUtil.showShortToast(context, context.getString(R.string.category_edit_helper_make_edit_toast)); |  | ||||||
|         return addCategory(media, categories, wikiText) |  | ||||||
|             .flatMapSingle(result -> Single.just(showCategoryEditNotification(context, media, result))) |  | ||||||
|             .firstOrError(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Rebuilds the WikiText with new categpries and post it on server |  | ||||||
|      * |  | ||||||
|      * @param media |  | ||||||
|      * @param categories to be added |  | ||||||
|      * @return |  | ||||||
|      */ |  | ||||||
|     private Observable<Boolean> addCategory(Media media, List<String> categories, |  | ||||||
|         final String wikiText) { |  | ||||||
|         Timber.d("thread is category adding %s", Thread.currentThread().getName()); |  | ||||||
|         String summary = "Adding categories"; |  | ||||||
|         final StringBuilder buffer = new StringBuilder(); |  | ||||||
|         final String wikiTextWithoutCategory; |  | ||||||
|         //If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category" |  | ||||||
|         if (wikiText.contains("Uncategorized")) { |  | ||||||
|             wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("Uncategorized")); |  | ||||||
|         } else if (wikiText.contains("[[Category")) { |  | ||||||
|             wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("[[Category")); |  | ||||||
|         } else { |  | ||||||
|             wikiTextWithoutCategory = ""; |  | ||||||
|         } |  | ||||||
|         if (categories != null && !categories.isEmpty()) { |  | ||||||
|             //If the categories list is empty, when reading the categories of a picture, |  | ||||||
|             // the code will add "None selected" to categories list in order to see in picture's categories with "None selected". |  | ||||||
|             // So that after selected some category,"None selected" should be removed from list |  | ||||||
|             for (int i = 0; i < categories.size(); i++) { |  | ||||||
|                 if (!categories.get(i).equals("None selected")//Not to add "None selected" as category to wikiText |  | ||||||
|                     || !wikiText.contains("Uncategorized")) { |  | ||||||
|                         buffer.append("[[Category:").append(categories.get(i)).append("]]\n"); |  | ||||||
|                     } |  | ||||||
|             } |  | ||||||
|             categories.remove("None selected"); |  | ||||||
|         } else { |  | ||||||
|             buffer.append("{{subst:unc}}"); |  | ||||||
|         } |  | ||||||
|         final String appendText = wikiTextWithoutCategory + buffer; |  | ||||||
|         return pageEditClient.edit(media.getFilename(), appendText + "\n", summary); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private boolean showCategoryEditNotification(Context context, Media media, boolean result) { |  | ||||||
|         String message; |  | ||||||
|         String title = context.getString(R.string.category_edit_helper_show_edit_title); |  | ||||||
| 
 |  | ||||||
|         if (result) { |  | ||||||
|             title += ": " + context.getString(R.string.category_edit_helper_show_edit_title_success); |  | ||||||
|             StringBuilder categoriesInMessage = new StringBuilder(); |  | ||||||
|             List<String> mediaCategoryList = media.getCategories(); |  | ||||||
|             for (String category : mediaCategoryList) { |  | ||||||
|                 categoriesInMessage.append(category); |  | ||||||
|                 if (category.equals(mediaCategoryList.get(mediaCategoryList.size()-1))) { |  | ||||||
|                     continue; |  | ||||||
|                 } |  | ||||||
|                 categoriesInMessage.append(","); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             message = context.getResources().getQuantityString(R.plurals.category_edit_helper_show_edit_message_if, mediaCategoryList.size(), categoriesInMessage.toString()); |  | ||||||
|         } else { |  | ||||||
|             title += ": " + context.getString(R.string.category_edit_helper_show_edit_title); |  | ||||||
|             message = context.getString(R.string.category_edit_helper_edit_message_else) ; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename(); |  | ||||||
|         Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)); |  | ||||||
|         notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_CATEGORY, browserIntent); |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public interface  Callback { |  | ||||||
|         boolean updateCategoryDisplay(List<String> categories); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,144 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | import android.content.Context | ||||||
|  | import android.content.Intent | ||||||
|  | import android.net.Uri | ||||||
|  | import fr.free.nrw.commons.BuildConfig | ||||||
|  | import fr.free.nrw.commons.Media | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.actions.PageEditClient | ||||||
|  | import fr.free.nrw.commons.notification.NotificationHelper | ||||||
|  | import fr.free.nrw.commons.utils.ViewUtilWrapper | ||||||
|  | import io.reactivex.Observable | ||||||
|  | import io.reactivex.Single | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | import timber.log.Timber | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class CategoryEditHelper @Inject constructor( | ||||||
|  |     private val notificationHelper: NotificationHelper, | ||||||
|  |     @Named("commons-page-edit") val pageEditClient: PageEditClient, | ||||||
|  |     private val viewUtil: ViewUtilWrapper, | ||||||
|  |     @Named("username") private val username: String | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Public interface to edit categories | ||||||
|  |      * @param context | ||||||
|  |      * @param media | ||||||
|  |      * @param categories | ||||||
|  |      * @return | ||||||
|  |      */ | ||||||
|  |     fun makeCategoryEdit( | ||||||
|  |         context: Context, | ||||||
|  |         media: Media, | ||||||
|  |         categories: List<String>, | ||||||
|  |         wikiText: String | ||||||
|  |     ): Single<Boolean> { | ||||||
|  |         viewUtil.showShortToast( | ||||||
|  |             context, | ||||||
|  |             context.getString(R.string.category_edit_helper_make_edit_toast) | ||||||
|  |         ) | ||||||
|  |         return addCategory(media, categories, wikiText) | ||||||
|  |             .flatMapSingle { result -> | ||||||
|  |                 Single.just(showCategoryEditNotification(context, media, result)) | ||||||
|  |             } | ||||||
|  |             .firstOrError() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Rebuilds the WikiText with new categories and post it on server | ||||||
|  |      * | ||||||
|  |      * @param media | ||||||
|  |      * @param categories to be added | ||||||
|  |      * @return | ||||||
|  |      */ | ||||||
|  |     private fun addCategory( | ||||||
|  |         media: Media, | ||||||
|  |         categories: List<String>?, | ||||||
|  |         wikiText: String | ||||||
|  |     ): Observable<Boolean> { | ||||||
|  |         Timber.d("thread is category adding %s", Thread.currentThread().name) | ||||||
|  |         val summary = "Adding categories" | ||||||
|  |         val buffer = StringBuilder() | ||||||
|  | 
 | ||||||
|  |         // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category" | ||||||
|  |         val wikiTextWithoutCategory: String = when { | ||||||
|  |             wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized")) | ||||||
|  |             wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category")) | ||||||
|  |             else -> "" | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (!categories.isNullOrEmpty()) { | ||||||
|  |             // If the categories list is empty, when reading the categories of a picture, | ||||||
|  |             // the code will add "None selected" to categories list in order to see in picture's categories with "None selected". | ||||||
|  |             // So that after selecting some category, "None selected" should be removed from list | ||||||
|  |             for (category in categories) { | ||||||
|  |                 if (category != "None selected" || !wikiText.contains("Uncategorized")) { | ||||||
|  |                     buffer.append("[[Category:").append(category).append("]]\n") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             categories.dropWhile { | ||||||
|  |                 it == "None selected" | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             buffer.append("{{subst:unc}}") | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val appendText = wikiTextWithoutCategory + buffer | ||||||
|  |         return pageEditClient.edit(media.filename!!, "$appendText\n", summary) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showCategoryEditNotification( | ||||||
|  |         context: Context, | ||||||
|  |         media: Media, | ||||||
|  |         result: Boolean | ||||||
|  |     ): Boolean { | ||||||
|  |         val title: String | ||||||
|  |         val message: String | ||||||
|  | 
 | ||||||
|  |         if (result) { | ||||||
|  |             title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + | ||||||
|  |                     context.getString(R.string.category_edit_helper_show_edit_title_success) | ||||||
|  | 
 | ||||||
|  |             val categoriesInMessage = StringBuilder() | ||||||
|  |             val mediaCategoryList = media.categories | ||||||
|  |             for ((index, category) in mediaCategoryList?.withIndex()!!) { | ||||||
|  |                 categoriesInMessage.append(category) | ||||||
|  |                 if (index != mediaCategoryList.size - 1) { | ||||||
|  |                     categoriesInMessage.append(",") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             message = context.resources.getQuantityString( | ||||||
|  |                 R.plurals.category_edit_helper_show_edit_message_if, | ||||||
|  |                 mediaCategoryList.size, | ||||||
|  |                 categoriesInMessage.toString() | ||||||
|  |             ) | ||||||
|  |         } else { | ||||||
|  |             title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + | ||||||
|  |                     context.getString(R.string.category_edit_helper_show_edit_title) | ||||||
|  |             message = context.getString(R.string.category_edit_helper_edit_message_else) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}" | ||||||
|  |         val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)) | ||||||
|  |         notificationHelper.showNotification( | ||||||
|  |             context, | ||||||
|  |             title, | ||||||
|  |             message, | ||||||
|  |             NOTIFICATION_EDIT_CATEGORY, | ||||||
|  |             browserIntent | ||||||
|  |         ) | ||||||
|  |         return result | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     interface Callback { | ||||||
|  |         fun updateCategoryDisplay(categories: List<String>?): Boolean | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         const val NOTIFICATION_EDIT_CATEGORY = 1 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,13 +0,0 @@ | ||||||
| package fr.free.nrw.commons.category; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Callback for notifying the viewpager that the number of items have changed |  | ||||||
|  * and for requesting more images when the viewpager has been scrolled to its end. |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| public interface CategoryImagesCallback { |  | ||||||
|    void viewPagerNotifyDataSetChanged(); |  | ||||||
|    void onMediaClicked(int position); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | @ -0,0 +1,7 @@ | ||||||
|  | package fr.free.nrw.commons.category | ||||||
|  | 
 | ||||||
|  | interface CategoryImagesCallback { | ||||||
|  |     fun viewPagerNotifyDataSetChanged() | ||||||
|  | 
 | ||||||
|  |     fun onMediaClicked(position: Int) | ||||||
|  | } | ||||||
|  | @ -17,11 +17,13 @@ interface CategoryInterface { | ||||||
|      * @param itemLimit How many results are returned |      * @param itemLimit How many results are returned | ||||||
|      * @return |      * @return | ||||||
|      */ |      */ | ||||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14") |     @GET( | ||||||
|  |         "w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14", | ||||||
|  |     ) | ||||||
|     fun searchCategories( |     fun searchCategories( | ||||||
|         @Query("gsrsearch") filter: String?, |         @Query("gsrsearch") filter: String?, | ||||||
|         @Query("gsrlimit") itemLimit: Int, |         @Query("gsrlimit") itemLimit: Int, | ||||||
|         @Query("gsroffset") offset: Int |         @Query("gsroffset") offset: Int, | ||||||
|     ): Single<MwQueryResponse> |     ): Single<MwQueryResponse> | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -31,11 +33,13 @@ interface CategoryInterface { | ||||||
|      * @param itemLimit How many results are returned |      * @param itemLimit How many results are returned | ||||||
|      * @return |      * @return | ||||||
|      */ |      */ | ||||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70") |     @GET( | ||||||
|  |         "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", | ||||||
|  |     ) | ||||||
|     fun searchCategoriesForPrefix( |     fun searchCategoriesForPrefix( | ||||||
|         @Query("gacprefix") prefix: String?, |         @Query("gacprefix") prefix: String?, | ||||||
|         @Query("gaclimit") itemLimit: Int, |         @Query("gaclimit") itemLimit: Int, | ||||||
|         @Query("gacoffset") offset: Int |         @Query("gacoffset") offset: Int, | ||||||
|     ): Single<MwQueryResponse> |     ): Single<MwQueryResponse> | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -47,23 +51,40 @@ interface CategoryInterface { | ||||||
|      * @param offset offset |      * @param offset offset | ||||||
|      * @return MwQueryResponse |      * @return MwQueryResponse | ||||||
|      */ |      */ | ||||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70") |     @GET( | ||||||
|  |         "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", | ||||||
|  |     ) | ||||||
|     fun getCategoriesByName( |     fun getCategoriesByName( | ||||||
|         @Query("gacfrom") startingCategory: String?, |         @Query("gacfrom") startingCategory: String?, | ||||||
|         @Query("gacto") endingCategory: String?, |         @Query("gacto") endingCategory: String?, | ||||||
|         @Query("gaclimit") itemLimit: Int, |         @Query("gaclimit") itemLimit: Int, | ||||||
|         @Query("gacoffset") offset: Int |         @Query("gacoffset") offset: Int, | ||||||
|  |     ): Single<MwQueryResponse> | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetches non-hidden categories by titles. | ||||||
|  |      * | ||||||
|  |      * @param titles titles to fetch categories for (e.g. File:<P18 of a wikidata entity>) | ||||||
|  |      * @param itemLimit How many categories to return | ||||||
|  |      * @return MwQueryResponse | ||||||
|  |      */ | ||||||
|  |     @GET( | ||||||
|  |         "w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden", | ||||||
|  |     ) | ||||||
|  |     fun getCategoriesByTitles( | ||||||
|  |         @Query("titles") titles: String?, | ||||||
|  |         @Query("gcllimit") itemLimit: Int, | ||||||
|     ): Single<MwQueryResponse> |     ): Single<MwQueryResponse> | ||||||
| 
 | 
 | ||||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") |     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") | ||||||
|     fun getSubCategoryList( |     fun getSubCategoryList( | ||||||
|         @Query("gcmtitle") categoryName: String, |         @Query("gcmtitle") categoryName: String, | ||||||
|         @QueryMap(encoded = true) continuation: Map<String, String> |         @QueryMap(encoded = true) continuation: Map<String, String>, | ||||||
|     ): Single<MwQueryResponse> |     ): Single<MwQueryResponse> | ||||||
| 
 | 
 | ||||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") |     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") | ||||||
|     fun getParentCategoryList( |     fun getParentCategoryList( | ||||||
|         @Query("titles") categoryName: String?, |         @Query("titles") categoryName: String?, | ||||||
|         @QueryMap(encoded = true) continuation: Map<String, String> |         @QueryMap(encoded = true) continuation: Map<String, String>, | ||||||
|     ): Single<MwQueryResponse> |     ): Single<MwQueryResponse> | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,12 +4,13 @@ import android.os.Parcelable | ||||||
| import kotlinx.parcelize.Parcelize | import kotlinx.parcelize.Parcelize | ||||||
| 
 | 
 | ||||||
| @Parcelize | @Parcelize | ||||||
| data class CategoryItem(val name: String, val description: String?, | data class CategoryItem( | ||||||
|                         val thumbnail: String?, var isSelected: Boolean) : Parcelable { |     val name: String, | ||||||
| 
 |     val description: String?, | ||||||
|     override fun toString(): String { |     val thumbnail: String?, | ||||||
|         return "CategoryItem: '$name'" |     var isSelected: Boolean, | ||||||
|     } | ) : Parcelable { | ||||||
|  |     override fun toString(): String = "CategoryItem: '$name'" | ||||||
| 
 | 
 | ||||||
|     override fun equals(other: Any?): Boolean { |     override fun equals(other: Any?): Boolean { | ||||||
|         if (this === other) return true |         if (this === other) return true | ||||||
|  | @ -22,7 +23,5 @@ data class CategoryItem(val name: String, val description: String?, | ||||||
|         return true |         return true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     override fun hashCode(): Int { |     override fun hashCode(): Int = name.hashCode() | ||||||
|         return name.hashCode() |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,16 +2,16 @@ package fr.free.nrw.commons.category | ||||||
| 
 | 
 | ||||||
| import io.reactivex.Single | import io.reactivex.Single | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| abstract class ContinuationClient<Network, Domain> { | abstract class ContinuationClient<Network, Domain> { | ||||||
|     private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf() |     private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf() | ||||||
|     private val continuationExists: MutableMap<String, Boolean> = mutableMapOf() |     private val continuationExists: MutableMap<String, Boolean> = mutableMapOf() | ||||||
| 
 | 
 | ||||||
|     private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true |     private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true | ||||||
|  | 
 | ||||||
|     fun continuationRequest( |     fun continuationRequest( | ||||||
|         prefix: String, |         prefix: String, | ||||||
|         name: String, |         name: String, | ||||||
|         requestFunction: (Map<String, String>) -> Single<Network> |         requestFunction: (Map<String, String>) -> Single<Network>, | ||||||
|     ): Single<List<Domain>> { |     ): Single<List<Domain>> { | ||||||
|         val key = "$prefix$name" |         val key = "$prefix$name" | ||||||
|         return if (hasMorePagesFor(key)) { |         return if (hasMorePagesFor(key)) { | ||||||
|  | @ -21,9 +21,15 @@ abstract class ContinuationClient<Network, Domain> { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     abstract fun responseMapper(networkResult: Single<Network>, key: String?=null): Single<List<Domain>> |     abstract fun responseMapper( | ||||||
|  |         networkResult: Single<Network>, | ||||||
|  |         key: String? = null, | ||||||
|  |     ): Single<List<Domain>> | ||||||
| 
 | 
 | ||||||
|     fun handleContinuationResponse(continuation:Map<String,String>?, key:String?){ |     fun handleContinuationResponse( | ||||||
|  |         continuation: Map<String, String>?, | ||||||
|  |         key: String?, | ||||||
|  |     ) { | ||||||
|         if (key != null) { |         if (key != null) { | ||||||
|             continuationExists[key] = |             continuationExists[key] = | ||||||
|                 continuation?.let { continuation -> |                 continuation?.let { continuation -> | ||||||
|  | @ -33,7 +39,10 @@ abstract class ContinuationClient<Network, Domain> { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected fun resetContinuation(prefix: String, category: String) { |     protected fun resetContinuation( | ||||||
|  |         prefix: String, | ||||||
|  |         category: String, | ||||||
|  |     ) { | ||||||
|         continuationExists.remove("$prefix$category") |         continuationExists.remove("$prefix$category") | ||||||
|         continuationStore.remove("$prefix$category") |         continuationStore.remove("$prefix$category") | ||||||
|     } |     } | ||||||
|  | @ -44,9 +53,11 @@ abstract class ContinuationClient<Network, Domain> { | ||||||
|      * @param prefix |      * @param prefix | ||||||
|      * @param userName the username |      * @param userName the username | ||||||
|      */ |      */ | ||||||
|     protected fun resetUserContinuation(prefix: String, userName: String) { |     protected fun resetUserContinuation( | ||||||
|  |         prefix: String, | ||||||
|  |         userName: String, | ||||||
|  |     ) { | ||||||
|         continuationExists.remove("$prefix$userName") |         continuationExists.remove("$prefix$userName") | ||||||
|         continuationStore.remove("$prefix$userName") |         continuationStore.remove("$prefix$userName") | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
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
	
	 Thejas Elandassery
						Thejas Elandassery