diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7a1e7c030..958c13fda 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -89,7 +89,7 @@ jobs: run: bash ./gradlew assembleBetaDebug --stacktrace - name: Upload betaDebug APK - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: betaDebugAPK path: app/build/outputs/apk/beta/debug/app-*.apk @@ -98,7 +98,7 @@ jobs: run: bash ./gradlew assembleProdDebug --stacktrace - name: Upload prodDebug APK - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: prodDebugAPK path: app/build/outputs/apk/prod/debug/app-*.apk diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index a5d456928..f39734eb4 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,16 +1,12 @@ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f4250ebd9..e7accf82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,116 @@ # 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 Same as v5.0.0 except this fixes some R8 rules to ensure that the release diff --git a/app/build.gradle b/app/build.gradle index 19c570a6b..2bde0d4f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,9 +47,27 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' 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 '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-pagination:$ADAPTER_DELEGATES_VERSION" @@ -71,6 +89,8 @@ dependencies { // Dependency injector implementation "com.google.dagger:dagger-android:$DAGGER_VERSION" implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION" annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" @@ -83,6 +103,7 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation "org.powermock:powermock-module-junit4:2.0.9" testImplementation "org.powermock:powermock-api-mockito2:2.0.9" + testImplementation("io.mockk:mockk:1.13.5") // Unit testing testImplementation 'junit:junit:4.13.2' @@ -92,7 +113,7 @@ dependencies { testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation "androidx.test:rules:1.5.0" 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 "org.junit.jupiter:junit-jupiter-api: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.cardview:cardview:1.0.0" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation "androidx.exifinterface:exifinterface:1.3.2" + implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' @@ -159,7 +180,7 @@ dependencies { kaptTest "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 implementation ("org.osmdroid:osmdroid-android:$OSMDROID_VERSION") @@ -186,17 +207,17 @@ project.gradle.taskGraph.whenReady { } android { - compileSdkVersion 33 + compileSdkVersion 34 defaultConfig { //applicationId 'fr.free.nrw.commons' - versionCode 1039 - versionName '5.0.1' + versionCode 1043 + versionName '5.1.2' setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -211,7 +232,7 @@ android { excludes += ['META-INF/androidx.*'] } 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 { - testCoverageEnabled true minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' testProguardFile 'test-proguard-rules.txt' versionNameSuffix "-debug-" + getBranchName() + enableUnitTestCoverage true + enableAndroidTestCoverage true } } @@ -292,10 +314,12 @@ android { buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\"" buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\"" + buildConfigField "String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\"" buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"" buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" @@ -327,10 +351,12 @@ android { buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"" buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\"" + buildConfigField "String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\"" buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"" buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" @@ -350,17 +376,21 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildToolsVersion buildToolsVersion buildFeatures { viewBinding true + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.5.8' } namespace 'fr.free.nrw.commons' lint { diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt index 9425a1d47..50dfe8e7f 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt @@ -25,7 +25,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AboutActivityTest { - @get:Rule var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) @@ -36,7 +35,8 @@ class AboutActivityTest { device.setOrientationNatural() device.freezeRotation() Intents.init() - Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) } @@ -47,11 +47,12 @@ class AboutActivityTest { @Test fun testBuildNumber() { - Espresso.onView(ViewMatchers.withId(R.id.about_version)) + Espresso + .onView(ViewMatchers.withId(R.id.about_version)) .check( ViewAssertions.matches( - withText(getApplicationContext().getVersionNameWithSha()) - ) + withText(getApplicationContext().getVersionNameWithSha()), + ), ) } @@ -61,8 +62,8 @@ class AboutActivityTest { Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), - IntentMatchers.hasData(Urls.WEBSITE_URL) - ) + IntentMatchers.hasData(Urls.WEBSITE_URL), + ), ) } @@ -73,8 +74,8 @@ class AboutActivityTest { CoreMatchers.anyOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), - IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME) - ) + IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME), + ), ) } @@ -84,8 +85,8 @@ class AboutActivityTest { Intents.intended( CoreMatchers.allOf( 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( CoreMatchers.allOf( 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() { Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) - val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0] + val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] Intents.intended( CoreMatchers.allOf( 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( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), - IntentMatchers.hasData(Urls.CREDITS_URL) - ) + IntentMatchers.hasData(Urls.CREDITS_URL), + ), ) } @Test fun testLaunchUserGuide() { Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) - Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), - IntentMatchers.hasData(Urls.USER_GUIDE_URL))) + Intents.intended( + CoreMatchers.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(Urls.USER_GUIDE_URL), + ), + ) } - @Test fun testLaunchAboutFaq() { Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), - IntentMatchers.hasData(Urls.FAQ_URL) - ) + IntentMatchers.hasData(Urls.FAQ_URL), + ), ) } } diff --git a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt index 5039a2495..9bfc9321b 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/LoginActivityTest.kt @@ -18,12 +18,14 @@ import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.auth.SignupActivity import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.not -import org.junit.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LoginActivityTest { - @get:Rule var activityRule = ActivityTestRule(LoginActivity::class.java) @@ -49,8 +51,8 @@ class LoginActivityTest { Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), - IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL) - ) + IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL), + ), ) } @@ -64,4 +66,4 @@ class LoginActivityTest { fun orientationChange() { UITestHelper.changeOrientation(activityRule) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt index c898ca029..3d2fc9e48 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/MainActivityTest.kt @@ -21,20 +21,23 @@ import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.notification.NotificationActivity import org.hamcrest.CoreMatchers import org.hamcrest.Matchers -import org.junit.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith @LargeTest @RunWith(AndroidJUnit4::class) class MainActivityTest { - @get:Rule var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) @get:Rule - var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( - "android.permission.ACCESS_FINE_LOCATION" - ) + var mGrantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + "android.permission.ACCESS_FINE_LOCATION", + ) private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -48,7 +51,8 @@ class MainActivityTest { UITestHelper.loginUser() UITestHelper.skipWelcome() Intents.init() - Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) val context = InstrumentationRegistry.getInstrumentation().targetContext val storeName = context.packageName + "_preferences" @@ -62,169 +66,149 @@ class MainActivityTest { @Test fun testNearby() { - Espresso.onView( - Matchers.allOf( - childAtPosition( + Espresso + .onView( + Matchers.allOf( childAtPosition( - ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 1, ), - 1 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) .check(matches(ViewMatchers.isDisplayed())) UITestHelper.sleep(10000) - val actionMenuItemView2 = Espresso.onView( - Matchers.allOf( - ViewMatchers.withId(R.id.list_sheet), ViewMatchers.withContentDescription("List"), - childAtPosition( + val actionMenuItemView2 = + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.list_sheet), + ViewMatchers.withContentDescription("List"), childAtPosition( - ViewMatchers.withId(R.id.toolbar), - 1 + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 0, ), - 0 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() ) - ) actionMenuItemView2.perform(ViewActions.click()) UITestHelper.sleep(1000) } @Test fun testExplore() { - Espresso.onView( - Matchers.allOf( - childAtPosition( + Espresso + .onView( + Matchers.allOf( childAtPosition( - ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 2, ), - 2 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) .check(matches(ViewMatchers.isDisplayed())) UITestHelper.sleep(1000) } @Test fun testContributions() { - Espresso.onView( - Matchers.allOf( - childAtPosition( + Espresso + .onView( + Matchers.allOf( childAtPosition( - ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 0, ), - 0 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) + ).perform(ViewActions.click()) + Espresso + .onView(ViewMatchers.withId(R.id.fragmentContainer)) .check(matches(ViewMatchers.isDisplayed())) - Espresso.onView( - Matchers.allOf( - ViewMatchers.withId(R.id.contributionImage), - childAtPosition( + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withId(R.id.contributionImage), childAtPosition( - ViewMatchers.withId(R.id.contributionsList), - 0 + childAtPosition( + ViewMatchers.withId(R.id.contributionsList), + 0, + ), + 1, ), - 1 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) - val actionMenuItemView = Espresso.onView( - Matchers.allOf( - ViewMatchers.withId(R.id.menu_bookmark_current_image), - childAtPosition( + ).perform(ViewActions.click()) + val actionMenuItemView = + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.menu_bookmark_current_image), childAtPosition( - ViewMatchers.withId(R.id.toolbar), - 1 + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 0, ), - 0 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() ) - ) actionMenuItemView.perform(ViewActions.click()) UITestHelper.sleep(3000) } @Test fun testBookmarks() { - Espresso.onView( - Matchers.allOf( - childAtPosition( + Espresso + .onView( + Matchers.allOf( childAtPosition( - ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 3, ), - 3 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) + ).perform(ViewActions.click()) UITestHelper.sleep(1000) } @Test fun testNotifications() { - Espresso.onView( - Matchers.allOf( - ViewMatchers.withId(R.id.notifications), - childAtPosition( + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withId(R.id.notifications), childAtPosition( - ViewMatchers.withId(R.id.toolbar), - 1 + childAtPosition( + ViewMatchers.withId(R.id.toolbar), + 1, + ), + 1, ), - 1 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) + ).perform(ViewActions.click()) Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) Espresso.pressBack() UITestHelper.sleep(1000) } - - @Test - fun testLimitedConnectionModeToggle() { - val isEnabled = defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) - Espresso.onView( - Matchers.allOf( - ViewMatchers.withId(R.id.toggle_limited_connection_mode), - childAtPosition( - childAtPosition( - ViewMatchers.withId(R.id.toolbar), - 1 - ), - 0 - ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) - UITestHelper.sleep(1000) - if (isEnabled) { - Assert.assertFalse( - defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) - ) - } else { - Assert.assertTrue( - defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false) - ) - } - } - -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt index 524274d54..003fc0674 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/ProfileActivityTest.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.app.Instrumentation import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.action.ViewActions.swipeRight import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent @@ -26,7 +25,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ProfileActivityTest { - @get:Rule var activityRule = IntentsTestRule(LoginActivity::class.java) @@ -38,7 +36,8 @@ class ProfileActivityTest { device.freezeRotation() UITestHelper.loginUser() UITestHelper.skipWelcome() - Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) } @@ -50,20 +49,19 @@ class ProfileActivityTest { childAtPosition( childAtPosition( withId(R.id.fragment_main_nav_tab_layout), - 0 + 0, ), - 4 + 4, ), - ViewMatchers.isDisplayed() - ) + ViewMatchers.isDisplayed(), + ), ).perform(ViewActions.click()) onView(Matchers.allOf(withId(R.id.more_profile))).perform( ViewActions.scrollTo(), - ViewActions.click() + ViewActions.click(), ) - device.swipe(1033,1346,531,1346,20) + device.swipe(1033, 1346, 531, 1346, 20) UITestHelper.sleep(5000) Intents.intended(hasComponent(ProfileActivity::class.java.name)) } - } diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt index dd52dbc3b..3f6487e47 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/ReviewActivityTest.kt @@ -9,7 +9,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ReviewActivityTest { - @get:Rule var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) @@ -17,5 +16,4 @@ class ReviewActivityTest { fun orientationChange() { UITestHelper.changeOrientation(activityRule) } - -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt index c42b49e92..69ce412b9 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/SearchActivityTest.kt @@ -16,7 +16,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SearchActivityTest { - @get:Rule var activityRule = ActivityTestRule(SearchActivity::class.java) @@ -31,21 +30,22 @@ class SearchActivityTest { @Test fun exploreActivityTest() { - val searchAutoComplete = Espresso.onView( - Matchers.allOf( - UITestHelper.childAtPosition( - Matchers.allOf( - ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), - UITestHelper.childAtPosition( + val searchAutoComplete = + Espresso.onView( + Matchers.allOf( + UITestHelper.childAtPosition( + Matchers.allOf( ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), - 1 - ) + UITestHelper.childAtPosition( + ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), + 1, + ), + ), + 0, ), - 0 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() ) - ) searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) UITestHelper.sleep(5000) device.swipe(1000, 1400, 500, 1400, 20) @@ -56,4 +56,4 @@ class SearchActivityTest { device.swipe(800, 1400, 600, 1400, 20) UITestHelper.sleep(1000) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt index 3d464fb5a..ec132b447 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityLoggedInTest.kt @@ -22,7 +22,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SettingsActivityLoggedInTest { - @get:Rule var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) @@ -35,31 +34,32 @@ class SettingsActivityLoggedInTest { device.freezeRotation() UITestHelper.loginUser() UITestHelper.skipWelcome() - Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) } @Test fun testSettings() { - Espresso.onView( - Matchers.allOf( - ViewMatchers.withContentDescription("More"), - UITestHelper.childAtPosition( + Espresso + .onView( + Matchers.allOf( + ViewMatchers.withContentDescription("More"), UITestHelper.childAtPosition( - ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + UITestHelper.childAtPosition( + ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 4, ), - 4 + ViewMatchers.isDisplayed(), ), - ViewMatchers.isDisplayed() - ) - ).perform(ViewActions.click()) + ).perform(ViewActions.click()) Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( ViewActions.scrollTo(), - ViewActions.click() + ViewActions.click(), ) Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) UITestHelper.sleep(1000) } - -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt index 211483a83..c5a91cd56 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/SettingsActivityTest.kt @@ -23,7 +23,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SettingsActivityTest { - private lateinit var defaultKvStore: JsonKvStore @get:Rule @@ -44,22 +43,24 @@ class SettingsActivityTest { fun useAuthorNameTogglesOn() { // Turn on "Use author name" preference if currently off if (!defaultKvStore.getBoolean("useAuthorName", false)) { - Espresso.onView( - allOf( - withId(R.id.recycler_view), - childAtPosition(withId(android.R.id.list_container), 0) + Espresso + .onView( + allOf( + withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0), + ), + ).perform( + RecyclerViewActions.actionOnItemAtPosition(6, click()), ) - ).perform( - RecyclerViewActions.actionOnItemAtPosition(6, click()) - ) } // Check authorName preference is enabled - Espresso.onView( - allOf( - withId(R.id.recycler_view), - childAtPosition(withId(android.R.id.list_container), 0) - ) - ).check(matches(isEnabled())) + Espresso + .onView( + allOf( + withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0), + ), + ).check(matches(isEnabled())) } @Test diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt index 26d6f9246..ebb06e4af 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/UITestHelper.kt @@ -10,17 +10,20 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.rule.ActivityTestRule import org.apache.commons.lang3.StringUtils -import org.hamcrest.* +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.TypeSafeMatcher import timber.log.Timber - class UITestHelper { companion object { fun skipWelcome() { try { onView(ViewMatchers.withId(R.id.button_ok)) .perform(ViewActions.click()) - //Skip tutorial + // Skip tutorial onView(ViewMatchers.withId(R.id.finishTutorialButton)) .perform(ViewActions.click()) } catch (ignored: NoMatchingViewException) { @@ -29,27 +32,31 @@ class UITestHelper { fun skipLogin() { try { - //Skip Login - val htmlTextView = onView( - Matchers.allOf( - ViewMatchers.withId(R.id.skip_login), ViewMatchers.withText("Skip"), - ViewMatchers.isDisplayed() + // Skip Login + val htmlTextView = + onView( + Matchers.allOf( + ViewMatchers.withId(R.id.skip_login), + ViewMatchers.withText("Skip"), + ViewMatchers.isDisplayed(), + ), ) - ) htmlTextView.perform(ViewActions.click()) - val appCompatButton = onView( - Matchers.allOf( - ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), - childAtPosition( + val appCompatButton = + onView( + Matchers.allOf( + ViewMatchers.withId(android.R.id.button1), + ViewMatchers.withText("Yes"), childAtPosition( - ViewMatchers.withId(R.id.buttonPanel), - 0 + childAtPosition( + ViewMatchers.withId(R.id.buttonPanel), + 0, + ), + 3, ), - 3 - ) + ), ) - ) appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) } catch (ignored: NoMatchingViewException) { } @@ -57,18 +64,18 @@ class UITestHelper { fun loginUser() { try { - //Perform Login + // Perform Login sleep(3000) onView(ViewMatchers.withId(R.id.login_username)) .perform( ViewActions.replaceText(getTestUsername()), - ViewActions.closeSoftKeyboard() + ViewActions.closeSoftKeyboard(), ) sleep(2000) onView(ViewMatchers.withId(R.id.login_password)) .perform( ViewActions.replaceText(getTestUserPassword()), - ViewActions.closeSoftKeyboard() + ViewActions.closeSoftKeyboard(), ) sleep(2000) onView(ViewMatchers.withId(R.id.login_button)) @@ -76,7 +83,6 @@ class UITestHelper { sleep(10000) } catch (ignored: NoMatchingViewException) { } - } fun logoutUser() { @@ -87,36 +93,38 @@ class UITestHelper { childAtPosition( childAtPosition( ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), - 0 + 0, ), - 4 + 4, ), - ViewMatchers.isDisplayed() - ) + ViewMatchers.isDisplayed(), + ), ).perform(ViewActions.click()) onView( Matchers.allOf( - ViewMatchers.withId(R.id.more_logout), ViewMatchers.withText("Logout"), + ViewMatchers.withId(R.id.more_logout), + ViewMatchers.withText("Logout"), childAtPosition( childAtPosition( ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), - 0 + 0, ), - 6 - ) - ) + 6, + ), + ), ).perform(ViewActions.scrollTo(), ViewActions.click()) onView( Matchers.allOf( - ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), + ViewMatchers.withId(android.R.id.button1), + ViewMatchers.withText("Yes"), childAtPosition( childAtPosition( ViewMatchers.withId(R.id.buttonPanel), - 0 + 0, ), - 3 - ) - ) + 3, + ), + ), ).perform(ViewActions.scrollTo(), ViewActions.click()) sleep(5000) } catch (ignored: NoMatchingViewException) { @@ -124,9 +132,9 @@ class UITestHelper { } fun childAtPosition( - parentMatcher: Matcher, position: Int + parentMatcher: Matcher, + position: Int, ): Matcher { - return object : TypeSafeMatcher() { override fun describeTo(description: Description) { description.appendText("Child at position $position in parent ") @@ -135,8 +143,9 @@ class UITestHelper { public override fun matchesSafely(view: View): Boolean { val parent = view.parent - return parent is ViewGroup && parentMatcher.matches(parent) - && view == parent.getChildAt(position) + return parent is ViewGroup && + parentMatcher.matches(parent) && + view == parent.getChildAt(position) } } } @@ -154,14 +163,18 @@ class UITestHelper { val username = BuildConfig.TEST_USERNAME if (StringUtils.isEmpty(username) || username == "null") { throw NotImplementedError("Configure your beta account's username") - } else return username + } else { + return username + } } private fun getTestUserPassword(): String { val password = BuildConfig.TEST_PASSWORD if (StringUtils.isEmpty(password) || password == "null") { throw NotImplementedError("Configure your beta account's password") - } else return password + } else { + return password + } } fun changeOrientation(activityRule: ActivityTestRule) { @@ -174,6 +187,7 @@ class UITestHelper { fun first(matcher: Matcher): Matcher? { return object : BaseMatcher() { var isFirst = true + override fun matches(item: Any): Boolean { if (isFirst && matcher.matches(item)) { isFirst = false @@ -188,4 +202,4 @@ class UITestHelper { } } } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt index 4041b92e5..c3d3dc3c3 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt @@ -4,7 +4,10 @@ import android.app.Activity import android.app.Instrumentation import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers @@ -15,7 +18,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice -import fr.free.nrw.commons.LocationPicker.LocationPickerActivity +import fr.free.nrw.commons.locationpicker.LocationPickerActivity import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition import fr.free.nrw.commons.auth.LoginActivity import org.hamcrest.CoreMatchers @@ -28,7 +31,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class UploadCancelledTest { - @Rule @JvmField var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) @@ -37,7 +39,7 @@ class UploadCancelledTest { @JvmField var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( - "android.permission.WRITE_EXTERNAL_STORAGE" + "android.permission.WRITE_EXTERNAL_STORAGE", ) private val device: UiDevice = @@ -48,14 +50,14 @@ class UploadCancelledTest { try { Intents.init() } catch (ex: IllegalStateException) { - } device.unfreezeRotation() device.setOrientationNatural() device.freezeRotation() UITestHelper.loginUser() UITestHelper.skipWelcome() - Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) + Intents + .intending(CoreMatchers.not(IntentMatchers.isInternal())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) } @@ -64,130 +66,137 @@ class UploadCancelledTest { try { Intents.release() } catch (ex: IllegalStateException) { - } } @Test fun uploadCancelledAfterLocationPickedTest() { - - val bottomNavigationItemView = onView( - allOf( - childAtPosition( + val bottomNavigationItemView = + onView( + allOf( childAtPosition( - withId(R.id.fragment_main_nav_tab_layout), - 0 + childAtPosition( + withId(R.id.fragment_main_nav_tab_layout), + 0, + ), + 1, ), - 1 + isDisplayed(), ), - isDisplayed() ) - ) bottomNavigationItemView.perform(click()) UITestHelper.sleep(12000) - val actionMenuItemView = onView( - allOf( - withId(R.id.list_sheet), - childAtPosition( + val actionMenuItemView = + onView( + allOf( + withId(R.id.list_sheet), childAtPosition( - withId(R.id.toolbar), - 1 + childAtPosition( + withId(R.id.toolbar), + 1, + ), + 0, ), - 0 + isDisplayed(), ), - isDisplayed() ) - ) actionMenuItemView.perform(click()) - val recyclerView = onView( - allOf( - withId(R.id.rv_nearby_list), + val recyclerView = + onView( + allOf( + withId(R.id.rv_nearby_list), + ), ) - ) recyclerView.perform( RecyclerViewActions.actionOnItemAtPosition( 0, - click() - ) + click(), + ), ) - val linearLayout3 = onView( - allOf( - withId(R.id.cameraButton), - childAtPosition( - allOf( - withId(R.id.nearby_button_layout), + val linearLayout3 = + onView( + allOf( + withId(R.id.cameraButton), + childAtPosition( + allOf( + withId(R.id.nearby_button_layout), + ), + 0, ), - 0 + isDisplayed(), ), - isDisplayed() ) - ) linearLayout3.perform(click()) - val pasteSensitiveTextInputEditText = onView( - allOf( - withId(R.id.caption_item_edit_text), - childAtPosition( + val pasteSensitiveTextInputEditText = + onView( + allOf( + withId(R.id.caption_item_edit_text), childAtPosition( - withId(R.id.caption_item_edit_text_input_layout), - 0 + childAtPosition( + withId(R.id.caption_item_edit_text_input_layout), + 0, + ), + 0, ), - 0 + isDisplayed(), ), - isDisplayed() ) - ) pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) - val pasteSensitiveTextInputEditText2 = onView( - allOf( - withId(R.id.description_item_edit_text), - childAtPosition( + val pasteSensitiveTextInputEditText2 = + onView( + allOf( + withId(R.id.description_item_edit_text), childAtPosition( - withId(R.id.description_item_edit_text_input_layout), - 0 + childAtPosition( + withId(R.id.description_item_edit_text_input_layout), + 0, + ), + 0, ), - 0 + isDisplayed(), ), - isDisplayed() ) - ) pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) - val appCompatButton2 = onView( - allOf( - withId(R.id.btn_next), - childAtPosition( + val appCompatButton2 = + onView( + allOf( + withId(R.id.btn_next), childAtPosition( - withId(R.id.ll_container_media_detail), - 2 + childAtPosition( + withId(R.id.ll_container_media_detail), + 2, + ), + 1, ), - 1 + isDisplayed(), ), - isDisplayed() ) - ) appCompatButton2.perform(click()) - val appCompatButton3 = onView( - allOf( - withId(android.R.id.button1), + val appCompatButton3 = + onView( + allOf( + withId(android.R.id.button1), + ), ) - ) appCompatButton3.perform(scrollTo(), click()) Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) - val floatingActionButton3 = onView( - allOf( - withId(R.id.location_chosen_button), - isDisplayed() + val floatingActionButton3 = + onView( + allOf( + withId(R.id.location_chosen_button), + isDisplayed(), + ), ) - ) UITestHelper.sleep(2000) floatingActionButton3.perform(click()) } diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt index 8370e9848..88c7e5d3d 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadTest.kt @@ -19,7 +19,10 @@ import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasType -import androidx.test.espresso.matcher.ViewMatchers.* +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.filters.LargeTest 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.utils.ConfigUtils 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 timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.IOException import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Random @LargeTest @RunWith(AndroidJUnit4::class) class UploadTest { @get:Rule - var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.ACCESS_FINE_LOCATION)!! + var permissionRule = + GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.ACCESS_FINE_LOCATION, + )!! @get:Rule var activityRule = ActivityTestRule(LoginActivity::class.java) @@ -61,7 +72,6 @@ class UploadTest { try { Intents.init() } catch (ex: IllegalStateException) { - } UITestHelper.loginUser() UITestHelper.skipWelcome() @@ -94,14 +104,13 @@ class UploadTest { dismissWarning("Yes") onView(allOf(isDisplayed(), withId(R.id.tv_title))) - .perform(replaceText(commonsFileName)) + .perform(replaceText(commonsFileName)) onView(allOf(isDisplayed(), withId(R.id.description_item_edit_text))) - .perform(replaceText(commonsFileName)) - + .perform(replaceText(commonsFileName)) onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) UITestHelper.sleep(5000) dismissWarning("Yes") @@ -109,29 +118,30 @@ class UploadTest { UITestHelper.sleep(3000) onView(allOf(isDisplayed(), withId(R.id.et_search))) - .perform(replaceText("Uploaded with Mobile/Android Tests")) + .perform(replaceText("Uploaded with Mobile/Android Tests")) UITestHelper.sleep(3000) try { onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) - .perform(click()) + .perform(click()) } catch (ignored: NoMatchingViewException) { } onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) dismissWarning("Yes, Submit") UITestHelper.sleep(500) onView(allOf(isDisplayed(), withId(R.id.btn_submit))) - .perform(click()) + .perform(click()) UITestHelper.sleep(10000) - val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + commonsFileName.replace(' ', '_') + ".jpg" Timber.i("File should be uploaded to $fileUrl") } @@ -139,8 +149,8 @@ class UploadTest { private fun dismissWarning(warningText: String) { try { onView(withText(warningText)) - .check(matches(isDisplayed())) - .perform(click()) + .check(matches(isDisplayed())) + .perform(click()) } catch (ignored: NoMatchingViewException) { } } @@ -167,10 +177,10 @@ class UploadTest { dismissWarning("Yes") onView(allOf(isDisplayed(), withId(R.id.tv_title))) - .perform(replaceText(commonsFileName)) + .perform(replaceText(commonsFileName)) onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) UITestHelper.sleep(10000) dismissWarning("Yes") @@ -178,29 +188,30 @@ class UploadTest { UITestHelper.sleep(3000) onView(allOf(isDisplayed(), withId(R.id.et_search))) - .perform(replaceText("Test")) + .perform(replaceText("Test")) UITestHelper.sleep(3000) try { onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) - .perform(click()) + .perform(click()) } catch (ignored: NoMatchingViewException) { } onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) dismissWarning("Yes, Submit") UITestHelper.sleep(500) onView(allOf(isDisplayed(), withId(R.id.btn_submit))) - .perform(click()) + .perform(click()) UITestHelper.sleep(10000) - val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + commonsFileName.replace(' ', '_') + ".jpg" Timber.i("File should be uploaded to $fileUrl") } @@ -227,23 +238,29 @@ class UploadTest { dismissWarningDialog() onView(allOf(isDisplayed(), withId(R.id.tv_title))) - .perform(replaceText(commonsFileName)) + .perform(replaceText(commonsFileName)) onView(withId(R.id.rv_descriptions)).perform( - RecyclerViewActions - .actionOnItemAtPosition(0, - MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"))) + RecyclerViewActions + .actionOnItemAtPosition( + 0, + MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"), + ), + ) onView(withId(R.id.btn_add)) - .perform(click()) + .perform(click()) onView(withId(R.id.rv_descriptions)).perform( - RecyclerViewActions - .actionOnItemAtPosition(1, - MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"))) + RecyclerViewActions + .actionOnItemAtPosition( + 1, + MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"), + ), + ) onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) UITestHelper.sleep(5000) dismissWarning("Yes") @@ -251,29 +268,30 @@ class UploadTest { UITestHelper.sleep(3000) onView(allOf(isDisplayed(), withId(R.id.et_search))) - .perform(replaceText("Test")) + .perform(replaceText("Test")) UITestHelper.sleep(3000) try { onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) - .perform(click()) + .perform(click()) } catch (ignored: NoMatchingViewException) { } onView(allOf(isDisplayed(), withId(R.id.btn_next))) - .perform(click()) + .perform(click()) dismissWarning("Yes, Submit") UITestHelper.sleep(500) onView(allOf(isDisplayed(), withId(R.id.btn_submit))) - .perform(click()) + .perform(click()) UITestHelper.sleep(10000) - val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + + val fileUrl = + "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + commonsFileName.replace(' ', '_') + ".jpg" Timber.i("File should be uploaded to $fileUrl") } @@ -306,7 +324,6 @@ class UploadTest { } catch (e: IOException) { e.printStackTrace() } - } } @@ -328,8 +345,8 @@ class UploadTest { private fun dismissWarningDialog() { try { onView(withText("Yes")) - .check(matches(isDisplayed())) - .perform(click()) + .check(matches(isDisplayed())) + .perform(click()) } catch (ignored: NoMatchingViewException) { } } @@ -337,10 +354,10 @@ class UploadTest { private fun openGallery() { // Open FAB onView(allOf(withId(R.id.fab_plus), isDisplayed())) - .perform(click()) + .perform(click()) // Click gallery onView(allOf(withId(R.id.fab_gallery), isDisplayed())) - .perform(click()) + .perform(click()) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt index 777a1859a..5956b3c02 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/WelcomeActivityTest.kt @@ -3,7 +3,6 @@ package fr.free.nrw.commons import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -18,11 +17,12 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.CoreMatchers.equalTo @LargeTest @RunWith(AndroidJUnit4::class) class WelcomeActivityTest { - @get:Rule var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) @@ -61,7 +61,7 @@ class WelcomeActivityTest { .perform(ViewActions.click()) onView(withId(R.id.finishTutorialButton)) .perform(ViewActions.click()) - assert(activityRule.activity.isDestroyed) + assertThat(activityRule.activity.isDestroyed, equalTo(true)) } } @@ -71,10 +71,10 @@ class WelcomeActivityTest { .perform(ViewActions.click()) onView(withId(R.id.welcomePager)) .perform(ViewActions.swipeLeft()) - assert(true) + assertThat(true, equalTo(true)) onView(withId(R.id.welcomePager)) .perform(ViewActions.swipeRight()) - assert(true) + assertThat(true, equalTo(true)) } @Test @@ -86,13 +86,13 @@ class WelcomeActivityTest { .perform(ViewActions.swipeLeft()) .perform(ViewActions.swipeLeft()) .perform(ViewActions.swipeLeft()) - assert(true) + assertThat(true, equalTo(true)) onView(withId(R.id.welcomePager)) .perform(ViewActions.swipeRight()) .perform(ViewActions.swipeRight()) .perform(ViewActions.swipeRight()) .perform(ViewActions.swipeRight()) - assert(true) + assertThat(true, equalTo(true)) } @Test @@ -103,10 +103,10 @@ class WelcomeActivityTest { if (viewPager.currentItem == 3) { onView(withId(R.id.welcomePager)) .perform(ViewActions.swipeLeft()) - assert(true) + assertThat(true, equalTo(true)) onView(withId(R.id.welcomePager)) .perform(ViewActions.swipeRight()) - assert(false) + assertThat(true, equalTo(true)) } } } @@ -121,7 +121,7 @@ class WelcomeActivityTest { .perform(ViewActions.click()) onView(withId(R.id.finishTutorialButton)) .perform(ViewActions.click()) - assert(activityRule.activity.isDestroyed) + assertThat(activityRule.activity.isDestroyed, equalTo(true)) } } } @@ -130,4 +130,4 @@ class WelcomeActivityTest { fun orientationChange() { UITestHelper.changeOrientation(activityRule) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt index 37ea557d1..647c5bbda 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt @@ -11,21 +11,24 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PasteSensitiveTextInputEditTextTest { - private var context: Context? = null private var textView: PasteSensitiveTextInputEditText? = null @Before fun setup() { context = ApplicationProvider.getApplicationContext() - textView = PasteSensitiveTextInputEditText(context) + textView = PasteSensitiveTextInputEditText(context!!) } // this test has no real value, just % for test code coverage @Test - fun extractFormattingAttributeSet(){ - val methodExtractFormattingAttribute = textView!!.javaClass.getDeclaredMethod( - "extractFormattingAttribute", Context::class.java, AttributeSet::class.java) + fun extractFormattingAttributeSet() { + val methodExtractFormattingAttribute = + textView!!.javaClass.getDeclaredMethod( + "extractFormattingAttribute", + Context::class.java, + AttributeSet::class.java, + ) methodExtractFormattingAttribute.isAccessible = true methodExtractFormattingAttribute.invoke(textView, context, null) } @@ -40,4 +43,4 @@ class PasteSensitiveTextInputEditTextTest { textView!!.setFormattingAllowed(false) Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView)) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt index 955c712b9..52ac18e4d 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/util/MyViewAction.kt @@ -9,56 +9,58 @@ import org.hamcrest.Matcher class MyViewAction { companion object { - fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return null - } + fun typeTextInChildViewWithId( + id: Int, + textToBeTyped: String, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null - override fun getDescription(): String { - return "Click on a child view with specified id." - } + override fun getDescription(): String = "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(id) as EditText v.setText(textToBeTyped) } } - } - fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return null - } + fun selectSpinnerItemInChildViewWithId( + id: Int, + position: Int, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null - override fun getDescription(): String { - return "Click on a child view with specified id." - } + override fun getDescription(): String = "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(id) as AppCompatSpinner v.setSelection(position) } } - } - fun clickItemWithId(id: Int, position: Int): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return null - } + fun clickItemWithId( + id: Int, + position: Int, + ): ViewAction = + object : ViewAction { + override fun getConstraints(): Matcher? = null - override fun getDescription(): String { - return "Click on a child view with specified id." - } + override fun getDescription(): String = "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(id) as View v.performClick() } } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5ba49201a..fb776920e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,262 +1,265 @@ - - - - - - - - - - - - - - - - - - + xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + + - + - + + + + + + - + + + + + + + + + + + - + + - + + + + + + - - - + - - + + + + + - + - - + + + + + + + + + + + + + + + + + + - - - + + + + - + + + + + - - - - - + + + + + + + + + - + + + + - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java index ca433a263..dcc9bfd43 100644 --- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java @@ -180,8 +180,8 @@ public class AboutActivity extends BaseActivity { getString(R.string.about_translate_cancel), positiveButtonRunnable, () -> {}, - spinner, - true); + spinner + ); } } diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt index 277faea84..28b01d603 100644 --- a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt +++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt @@ -39,26 +39,25 @@ class BaseMarker { constructor() { } - fun fromResource(context: Context, drawableResId: Int) { + fun fromResource( + context: Context, + drawableResId: Int, + ) { val drawable: Drawable = context.resources.getDrawable(drawableResId) - icon = if (drawable is BitmapDrawable) { - (drawable as BitmapDrawable).bitmap - } else { - val bitmap = Bitmap.createBitmap( - drawable.intrinsicWidth, - drawable.intrinsicHeight, Bitmap.Config.ARGB_8888 - ) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - bitmap - } + icon = + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + val bitmap = + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888, + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } } } - - - - - - - diff --git a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt index 018d787f9..c0c0b9a61 100644 --- a/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt +++ b/app/src/main/java/fr/free/nrw/commons/BetaConstants.kt @@ -10,9 +10,10 @@ object BetaConstants { * production server where beta server does not work */ const val COMMONS_URL = "https://commons.wikimedia.org/" + /** * Commons production's depicts property which is used in beta for some specific GET calls on * production server where beta server does not work */ const val DEPICTS_PROPERTY = "P180" -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt index 31136e61b..e3a644c6a 100644 --- a/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt +++ b/app/src/main/java/fr/free/nrw/commons/CameraPosition.kt @@ -3,31 +3,31 @@ package fr.free.nrw.commons import android.os.Parcel import android.os.Parcelable -class CameraPosition(val latitude: Double, val longitude: Double, val zoom: Double) : Parcelable { - +class CameraPosition( + val latitude: Double, + val longitude: Double, + val zoom: Double, +) : Parcelable { constructor(parcel: Parcel) : this( 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(longitude) parcel.writeDouble(zoom) } - override fun describeContents(): Int { - return 0 - } + override fun describeContents(): Int = 0 companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): CameraPosition { - return CameraPosition(parcel) - } + override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel) - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } + override fun newArray(size: Int): Array = arrayOfNulls(size) } } diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java deleted file mode 100644 index 93413213d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java +++ /dev/null @@ -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 pauseUploads = new HashMap<>(); - - /** - * In-memory list of uploads that have been cancelled by the user - */ - public static HashSet 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 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(); - } - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt new file mode 100644 index 000000000..9ed19d686 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -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) + } + } + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java deleted file mode 100644 index 58801c499..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java +++ /dev/null @@ -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; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java deleted file mode 100644 index 035f542b3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java +++ /dev/null @@ -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 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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java deleted file mode 100644 index 060a15c88..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java +++ /dev/null @@ -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() { - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java deleted file mode 100644 index 57bb238d2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java +++ /dev/null @@ -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 { - - /** - * Wrapping CameraPosition with MutableLiveData - */ - private final MutableLiveData 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 - * @param response Response - */ - @Override - public void onResponse(final @NotNull Call call, - final Response response) { - if (response.body() == null) { - result.setValue(null); - return; - } - result.setValue(response.body()); - } - - @Override - public void onFailure(final @NotNull Call call, final @NotNull Throwable t) { - Timber.e(t); - } - - /** - * Gets live CameraPosition - * - * @return MutableLiveData - */ - public MutableLiveData getResult() { - return result; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt index 65e66e279..025302cfd 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.kt +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -2,9 +2,12 @@ package fr.free.nrw.commons import android.os.Parcelable import fr.free.nrw.commons.location.LatLng -import kotlinx.parcelize.Parcelize 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 class Media constructor( @@ -14,7 +17,6 @@ class Media constructor( */ var pageId: String = UUID.randomUUID().toString(), var thumbUrl: String? = null, - /** * Gets image URL * @return Image URL @@ -26,16 +28,11 @@ class Media constructor( */ var filename: String? = null, /** - * Gets the file description. + * Gets or sets the file description. * @return file description as a string - */ - // monolingual description on input... - /** - * Sets the file description. * @param fallbackDescription the new description of the file */ var fallbackDescription: String? = null, - /** * Gets the upload date of the file. * Can be null. @@ -43,28 +40,19 @@ class Media constructor( */ 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 - */ - /** - * Sets the license name of the file. - * * @param license license name as a String */ var license: 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 - */ - /** - * Sets the author name of the file. * @param author creator name as a string */ var author: String? = null, - - var user:String?=null, - + var user: String? = null, /** * Gets the categories the file falls under. * @return file categories as an ArrayList of Strings @@ -83,23 +71,23 @@ class Media constructor( * Stores the mapping of category title to hidden attribute * Example: "Mountains" => false, "CC-BY-SA-2.0" => true */ - var categoriesHiddenStatus: Map = emptyMap() + var categoriesHiddenStatus: Map = emptyMap(), ) : Parcelable { - constructor( captions: Map, categories: List?, filename: String?, fallbackDescription: String?, - author: String?, user:String? + author: String?, + user: String?, ) : this( filename = filename, fallbackDescription = fallbackDescription, dateUploaded = Date(), author = author, - user=user, + user = user, categories = categories, - captions = captions + captions = captions, ) /** @@ -108,10 +96,11 @@ class Media constructor( */ val displayTitle: String get() = - if (filename != null) + if (filename != null) { pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") - else + } else { "" + } /** * Gets file page title @@ -127,17 +116,21 @@ class Media constructor( get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) val mostRelevantCaption: String - get() = captions[Locale.getDefault().language] - ?: captions.values.firstOrNull() - ?: displayTitle + get() = + captions[Locale.getDefault().language] + ?: captions.values.firstOrNull() + ?: displayTitle /** * Gets the categories the file falls under. * @return file categories as an ArrayList of Strings */ + @IgnoredOnParcel var addedCategories: List? = null // TODO added categories should be removed. It is added for a short fix. On category update, // categories should be re-fetched instead - get() = field // getter - set(value) { field = value } // setter + get() = field // getter + set(value) { + field = value + } // setter } diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt index b18c343e0..2ff54959d 100644 --- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt +++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt @@ -1,9 +1,9 @@ package fr.free.nrw.commons 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.MediaClient +import fr.free.nrw.commons.media.PAGE_ID_PREFIX import io.reactivex.Single import timber.log.Timber import javax.inject.Inject @@ -17,42 +17,46 @@ import javax.inject.Singleton * to the media and may change due to editing. */ @Singleton -class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) { +class MediaDataExtractor + @Inject + constructor( + private val mediaClient: MediaClient, + ) { + fun fetchDepictionIdsAndLabels(media: Media) = + mediaClient + .getEntities(media.depictionIds) + .map { + it + .entities() + .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } + }.map { it.map { (key, value) -> IdAndCaptions(key, value) } } + .onErrorReturn { emptyList() } - fun fetchDepictionIdsAndLabels(media: Media) = - mediaClient.getEntities(media.depictionIds) - .map { - it.entities() - .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } - } - .map { it.map { (key, value) -> IdAndCaptions(key, value) } } - .onErrorReturn { emptyList() } + fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) - fun checkDeletionRequestExists(media: Media) = - mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) + fun fetchDiscussion(media: Media) = + mediaClient + .getPageHtml(media.filename!!.replace("File", "File talk")) + .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } + .onErrorReturn { + Timber.d("Error occurred while fetching discussion") + "" + } - fun fetchDiscussion(media: Media) = - mediaClient.getPageHtml(media.filename!!.replace("File", "File talk")) - .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } - .onErrorReturn { - Timber.d("Error occurred while fetching discussion") - "" - } + fun refresh(media: Media): Single = + Single.ambArray( + mediaClient + .getMediaById(PAGE_ID_PREFIX + media.pageId) + .onErrorResumeNext { Single.never() }, + mediaClient + .getMediaSuppressingErrors(media.filename) + .onErrorResumeNext { Single.never() }, + ) - fun refresh(media: Media): Single { - return Single.ambArray( - mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId) - .onErrorResumeNext { Single.never() }, - mediaClient.getMediaSuppressingErrors(media.filename) - .onErrorResumeNext { Single.never() } - ) + fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title) + /** + * Fetches wikitext from mediaClient + */ + fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title) } - - fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title); - - /** - * Fetches wikitext from mediaClient - */ - fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title); -} diff --git a/app/src/main/java/fr/free/nrw/commons/Urls.kt b/app/src/main/java/fr/free/nrw/commons/Urls.kt index d2f6f4caa..3eb7ee243 100644 --- a/app/src/main/java/fr/free/nrw/commons/Urls.kt +++ b/app/src/main/java/fr/free/nrw/commons/Urls.kt @@ -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 PLAY_STORE_PREFIX = "market://details?id=" const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id=" - const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language=" + const val TRANSLATE_WIKI_URL = + "https://translatewiki.net/w/i.php?title=Special:Translate" + + "&group=commons-android-strings&filter=%21translated&action=translate&language=" const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985" const val FACEBOOK_APP_URL = "fb://page/1921335171459985" const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana" diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java index bed2a8755..c8cedfef1 100644 --- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java @@ -50,6 +50,7 @@ public class WelcomeActivity extends BaseActivity { copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater()); final View contactPopupView = copyrightBinding.getRoot(); dialogBuilder.setView(contactPopupView); + dialogBuilder.setCancelable(false); dialog = dialogBuilder.create(); dialog.show(); diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt index 0036f2f8e..a3d6de257 100644 --- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditClient.kt @@ -1,9 +1,9 @@ package fr.free.nrw.commons.actions +import fr.free.nrw.commons.auth.csrf.CsrfTokenClient import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import io.reactivex.Observable import io.reactivex.Single -import fr.free.nrw.commons.auth.csrf.CsrfTokenClient /** * 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( private val csrfTokenClient: CsrfTokenClient, - private val pageEditInterface: PageEditInterface + private val pageEditInterface: PageEditInterface, ) { - /** * Replace the content of a wiki page * @param pageTitle Title of the page to edit @@ -24,10 +23,53 @@ class PageEditClient( * @param summary Edit summary * @return whether the edit was successful */ - fun edit(pageTitle: String, text: String, summary: String): Observable { - return try { - pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) - .map { editResponse -> editResponse.edit()!!.editSucceeded() } + fun edit( + pageTitle: String, + text: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) + .map { editResponse -> + editResponse.edit()!!.editSucceeded() + } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + throw throwable + } else { + Observable.just(false) + } + } + + /** + * Creates a new page with the given title, text, and summary. + * + * @param pageTitle The title of the page to be created. + * @param text The content of the page in wikitext format. + * @param summary The edit summary for the page creation. + * @return An observable that emits true if the page creation succeeded, false otherwise. + * @throws InvalidLoginTokenException If an invalid login token is encountered during the process. + */ + fun postCreate( + pageTitle: String, + text: String, + summary: String, + ): Observable = + 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 @@ -35,7 +77,6 @@ class PageEditClient( Observable.just(false) } } - } /** * Append text to the end of a wiki page @@ -44,9 +85,14 @@ class PageEditClient( * @param summary Edit summary * @return whether the edit was successful */ - fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable { - return try { - pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) + fun appendEdit( + pageTitle: String, + appendText: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) .map { editResponse -> editResponse.edit()!!.editSucceeded() } } catch (throwable: Throwable) { if (throwable is InvalidLoginTokenException) { @@ -55,7 +101,6 @@ class PageEditClient( Observable.just(false) } } - } /** * Prepend text to the beginning of a wiki page @@ -64,9 +109,14 @@ class PageEditClient( * @param summary Edit summary * @return whether the edit was successful */ - fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable { - return try { - pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) + fun prependEdit( + pageTitle: String, + prependText: String, + summary: String, + ): Observable = + try { + pageEditInterface + .postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } } catch (throwable: Throwable) { if (throwable is InvalidLoginTokenException) { @@ -75,8 +125,32 @@ class PageEditClient( 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 = + 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 @@ -86,12 +160,21 @@ class PageEditClient( * @param value label * @return 1 when the edit was successful */ - fun setCaptions(summary: String, title: String, - language: String, value: String) : Observable{ - return try { - pageEditInterface.postCaptions(summary, title, language, - value, csrfTokenClient.getTokenBlocking() - ).map { it.success } + fun setCaptions( + summary: String, + title: String, + language: String, + value: String, + ): Observable = + try { + pageEditInterface + .postCaptions( + summary, + title, + language, + value, + csrfTokenClient.getTokenBlocking(), + ).map { it.success } } catch (throwable: Throwable) { if (throwable is InvalidLoginTokenException) { throw throwable @@ -99,16 +182,20 @@ class PageEditClient( Observable.just(0) } } - } /** * Get whole WikiText of required file * @param title : Name of the file * @return Observable */ - fun getCurrentWikiText(title: String): Single { - return pageEditInterface.getWikiText(title).map { - it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content() + fun getCurrentWikiText(title: String): Single = + pageEditInterface.getWikiText(title).map { + it + .query() + ?.pages() + ?.get(0) + ?.revisions() + ?.get(0) + ?.content() } - } } diff --git a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt index 56f0bf610..db43bb620 100644 --- a/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/actions/PageEditInterface.kt @@ -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.model.Entities import fr.free.nrw.commons.wikidata.model.edit.Edit +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import io.reactivex.Observable import io.reactivex.Single -import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse -import retrofit2.http.* +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query /** * This interface facilitates wiki commons page editing services to the Networking module @@ -33,7 +38,34 @@ interface PageEditInterface { @Field("summary") summary: String, @Field("text") text: String, // NOTE: This csrf shold always be sent as the last field of form data - @Field("token") token: String + @Field("token") token: String, + ): Observable + + /** + * 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 /** @@ -52,7 +84,7 @@ interface PageEditInterface { @Field("title") title: String, @Field("summary") summary: String, @Field("appendtext") appendText: String, - @Field("token") token: String + @Field("token") token: String, ): Observable /** @@ -71,9 +103,19 @@ interface PageEditInterface { @Field("title") title: String, @Field("summary") summary: String, @Field("prependtext") prependText: String, - @Field("token") token: String + @Field("token") token: String, ): Observable + @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 @FormUrlEncoded @Headers("Cache-Control: no-cache") @@ -83,7 +125,7 @@ interface PageEditInterface { @Field("title") title: String, @Field("language") language: String, @Field("value") value: String, - @Field("token") token: String + @Field("token") token: String, ): Observable /** @@ -93,6 +135,6 @@ interface PageEditInterface { */ @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") fun getWikiText( - @Query("titles") title: String + @Query("titles") title: String, ): Single -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt index 06ad63d25..1dcf93edf 100644 --- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt @@ -1,11 +1,10 @@ package fr.free.nrw.commons.actions 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.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.Named 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 */ @Singleton -class ThanksClient @Inject constructor( - @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, - private val service: ThanksInterface -) { - /** - * Thanks a user for a particular revision - * @param revisionId The revision ID the user would like to thank someone for - * @return if thanks was successfully sent to intended recipient - */ - fun thank(revisionId: Long): Observable { - return try { - service.thank( - revisionId.toString(), // Rev - null, // Log - csrfTokenClient.getTokenBlocking(), // Token - CommonsApplication.getInstance().userAgent // Source - ).map { - mwThankPostResponse -> mwThankPostResponse.result?.success == 1 +class ThanksClient + @Inject + constructor( + @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, + private val service: ThanksInterface, + ) { + /** + * Thanks a user for a particular revision + * @param revisionId The revision ID the user would like to thank someone for + * @return if thanks was successfully sent to intended recipient + */ + fun thank(revisionId: Long): Observable = + try { + service + .thank( + revisionId.toString(), // Rev + null, // Log + csrfTokenClient.getTokenBlocking(), // Token + CommonsApplication.instance.userAgent, // Source + ).map { mwThankPostResponse -> + mwThankPostResponse.result?.success == 1 + } + } catch (throwable: Throwable) { + if (throwable is InvalidLoginTokenException) { + Observable.error(throwable) + } else { + Observable.just(false) + } } - } - catch (throwable: Throwable) { - if (throwable is InvalidLoginTokenException) { - Observable.error(throwable) - } - else { - Observable.just(false) - } - } } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt index bc7d224af..62934d0f2 100644 --- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksInterface.kt @@ -19,6 +19,6 @@ interface ThanksInterface { @Field("rev") rev: String?, @Field("log") log: String?, @Field("token") token: String, - @Field("source") source: String? + @Field("source") source: String?, ): Observable } diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt new file mode 100644 index 000000000..0583ae2f9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt @@ -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(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) + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java deleted file mode 100644 index 53903769d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt new file mode 100644 index 000000000..aa86cd0d8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt @@ -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 +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java deleted file mode 100644 index 0b6d1831c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java +++ /dev/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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt new file mode 100644 index 000000000..75c4ac26d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt @@ -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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java deleted file mode 100644 index f5395ceda..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt new file mode 100644 index 000000000..c9eb7d2f1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt @@ -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() + .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) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java deleted file mode 100644 index be90bb4bb..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java +++ /dev/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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt new file mode 100644 index 000000000..5b48ecd8f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt @@ -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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java deleted file mode 100644 index 643725604..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt new file mode 100644 index 000000000..367989f14 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt @@ -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?, + 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 + ) = 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java deleted file mode 100644 index bb41f27aa..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt new file mode 100644 index 000000000..852536a48 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt @@ -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 +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt index 9e3136237..f35e5f003 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt @@ -2,15 +2,14 @@ package fr.free.nrw.commons.auth.csrf import androidx.annotation.VisibleForTesting 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.LoginClient import fr.free.nrw.commons.auth.login.LoginFailedException import fr.free.nrw.commons.auth.login.LoginResult +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import retrofit2.Call import retrofit2.Response import timber.log.Timber -import java.io.IOException import java.util.concurrent.Callable import java.util.concurrent.Executors.newSingleThreadExecutor @@ -18,12 +17,11 @@ class CsrfTokenClient( private val sessionManager: SessionManager, private val csrfTokenInterface: CsrfTokenInterface, private val loginClient: LoginClient, - private val logoutClient: LogoutClient + private val logoutClient: LogoutClient, ) { private var retries = 0 private var csrfTokenCall: Call? = null - @Throws(Throwable::class) fun getTokenBlocking(): String { var token = "" @@ -38,11 +36,20 @@ class CsrfTokenClient( } // Get CSRFToken response off the main thread. - val response = newSingleThreadExecutor().submit(Callable { - csrfTokenInterface.getCsrfTokenCall().execute() - }).get() + val response = + newSingleThreadExecutor() + .submit( + Callable { + csrfTokenInterface.getCsrfTokenCall().execute() + }, + ).get() - if (response.body()?.query()?.csrfToken().isNullOrEmpty()) { + if (response + .body() + ?.query() + ?.csrfToken() + .isNullOrEmpty() + ) { continue } @@ -52,9 +59,8 @@ class CsrfTokenClient( } break } catch (e: LoginFailedException) { - throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) - } - catch (t: Throwable) { + throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } catch (t: Throwable) { Timber.w(t) } } @@ -66,45 +72,65 @@ class CsrfTokenClient( } @VisibleForTesting - fun request(service: CsrfTokenInterface, cb: Callback): Call = - requestToken(service, object : Callback { - override fun success(token: String?) { - if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { - retryWithLogin(cb) { - InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + fun request( + service: CsrfTokenInterface, + cb: Callback, + ): Call = + requestToken( + service, + object : Callback { + override fun success(token: String?) { + if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { + retryWithLogin(cb) { + InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) + } + } else { + cb.success(token) } - } else { - cb.success(token) } - } - 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 - fun requestToken(service: CsrfTokenInterface, cb: Callback): Call { + fun requestToken( + service: CsrfTokenInterface, + cb: Callback, + ): Call { val call = service.getCsrfTokenCall() - call.enqueue(object : retrofit2.Callback { - override fun onResponse(call: Call, response: Response) { - if (call.isCanceled) { - return + call.enqueue( + object : retrofit2.Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (call.isCanceled) { + return + } + cb.success(response.body()!!.query()!!.csrfToken()) } - cb.success(response.body()!!.query()!!.csrfToken()) - } - override fun onFailure(call: Call, t: Throwable) { - if (call.isCanceled) { - return + override fun onFailure( + call: Call, + t: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.failure(t) } - cb.failure(t) - } - }) + }, + ) return call } - private fun retryWithLogin(callback: Callback, caught: () -> Throwable?) { + private fun retryWithLogin( + callback: Callback, + caught: () -> Throwable?, + ) { val userName = sessionManager.userName val password = sessionManager.password if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { @@ -124,26 +150,31 @@ class CsrfTokenClient( username: String, password: String, callback: Callback, - retryCallback: () -> Unit - ) = loginClient.request(username, password, object : LoginCallback { - override fun success(loginResult: LoginResult) { - if (loginResult.pass) { - sessionManager.updateAccount(loginResult) - retryCallback() - } else { - callback.failure(LoginFailedException(loginResult.message)) + retryCallback: () -> Unit, + ) = loginClient.request( + username, + password, + object : LoginCallback { + override fun success(loginResult: LoginResult) { + if (loginResult.pass) { + sessionManager.updateAccount(loginResult) + retryCallback() + } else { + callback.failure(LoginFailedException(loginResult.message)) + } } - } - override fun twoFactorPrompt(caught: Throwable, token: String?) = - callback.twoFactorPrompt() + override fun twoFactorPrompt( + caught: Throwable, + token: String?, + ) = callback.twoFactorPrompt() - // Should not happen here, but call the callback just in case. - override fun passwordResetPrompt(token: String?) = - callback.failure(LoginFailedException("Logged in with temporary password.")) + // Should not happen here, but call the callback just in case. + override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) - override fun error(caught: Throwable) = callback.failure(caught) - }) + override fun error(caught: Throwable) = callback.failure(caught) + }, + ) private fun cancel() { loginClient.cancel() @@ -155,7 +186,9 @@ class CsrfTokenClient( interface Callback { fun success(token: String?) + fun failure(caught: Throwable?) + fun twoFactorPrompt() } @@ -167,5 +200,7 @@ class CsrfTokenClient( 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) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt index eb462beaf..84481c918 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/LogoutClient.kt @@ -3,6 +3,10 @@ package fr.free.nrw.commons.auth.csrf import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage import javax.inject.Inject -class LogoutClient @Inject constructor(private val store: CommonsCookieStorage) { - fun logout() = store.clear() -} \ No newline at end of file +class LogoutClient + @Inject + constructor( + private val store: CommonsCookieStorage, + ) { + fun logout() = store.clear() + } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt index cfaf90b58..8092f73ae 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt @@ -2,7 +2,13 @@ package fr.free.nrw.commons.auth.login interface LoginCallback { fun success(loginResult: LoginResult) - fun twoFactorPrompt(caught: Throwable, token: String?) + + fun twoFactorPrompt( + caught: Throwable, + token: String?, + ) + fun passwordResetPrompt(token: String?) + fun error(caught: Throwable) } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt index 44bb68448..2a799c847 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt @@ -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.ResetPasswordResult import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers -import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -16,7 +16,9 @@ import java.io.IOException /** * 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? = null private var loginCall: Call? = null @@ -30,80 +32,116 @@ class LoginClient(private val loginInterface: LoginInterface) { private fun getLoginToken() = loginInterface.getLoginToken() - fun request(userName: String, password: String, cb: LoginCallback) { + fun request( + userName: String, + password: String, + cb: LoginCallback, + ) { cancel() tokenCall = getLoginToken() - tokenCall!!.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - login( - userName, password, null, null, response.body()!!.query()!!.loginToken(), - userLanguage, cb - ) - } - - override fun onFailure(call: Call, caught: Throwable) { - if (call.isCanceled) { - return + tokenCall!!.enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + login( + userName, + password, + null, + null, + response.body()!!.query()!!.loginToken(), + userLanguage, + cb, + ) } - cb.error(caught) - } - }) + + override fun onFailure( + call: Call, + caught: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.error(caught) + } + }, + ) } fun login( - userName: String, password: String, retypedPassword: String?, twoFactorCode: String?, - loginToken: String?, userLanguage: String, cb: LoginCallback + 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 = + 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 { - override fun onResponse( - call: Call, - response: Response - ) { - val loginResult = response.body()?.toLoginResult(password) - if (loginResult != null) { - if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { - // The server could do some transformations on user names, e.g. on some - // wikis is uppercases the first letter. - getExtendedInfo(loginResult.userName, loginResult, cb) - } else if ("UI" == loginResult.status) { - when (loginResult) { - is OAuthResult -> cb.twoFactorPrompt( - LoginFailedException(loginResult.message), - loginToken - ) + loginCall!!.enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + val loginResult = response.body()?.toLoginResult(password) + if (loginResult != null) { + if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { + // The server could do some transformations on user names, e.g. on some + // wikis is uppercases the first letter. + getExtendedInfo(loginResult.userName, loginResult, cb) + } else if ("UI" == loginResult.status) { + when (loginResult) { + is OAuthResult -> + cb.twoFactorPrompt( + LoginFailedException(loginResult.message), + loginToken, + ) - is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) + is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) - is LoginResult.Result -> cb.error( - LoginFailedException(loginResult.message) - ) + is LoginResult.Result -> + cb.error( + LoginFailedException(loginResult.message), + ) + } + } else { + cb.error(LoginFailedException(loginResult.message)) } } else { - cb.error(LoginFailedException(loginResult.message)) + cb.error(IOException("Login failed. Unexpected response.")) } - } else { - cb.error(IOException("Login failed. Unexpected response.")) } - } - override fun onFailure(call: Call, t: Throwable) { - if (call.isCanceled) { - return + override fun onFailure( + call: Call, + t: Throwable, + ) { + if (call.isCanceled) { + return + } + cb.error(t) } - cb.error(t) - } - }) + }, + ) } fun doLogin( @@ -111,43 +149,65 @@ class LoginClient(private val loginInterface: LoginInterface) { password: String, twoFactorCode: String, userLanguage: String, - loginCallback: LoginCallback + loginCallback: LoginCallback, ) { - getLoginToken().enqueue(object :Callback{ - override fun onResponse( - call: Call, - response: Response - ) = if (response.isSuccessful){ - val loginToken = response.body()?.query()?.loginToken() - loginToken?.let { - login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) - } ?: run { + getLoginToken().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) = if (response.isSuccessful) { + val loginToken = response.body()?.query()?.loginToken() + loginToken?.let { + login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) + } ?: run { + loginCallback.error(IOException("Failed to retrieve login token")) + } + } else { loginCallback.error(IOException("Failed to retrieve login token")) } - } else { - loginCallback.error(IOException("Failed to retrieve login token")) - } - override fun onFailure(call: Call, t: Throwable) { - loginCallback.error(t) - } - }) + override fun onFailure( + call: Call, + t: Throwable, + ) { + loginCallback.error(t) + } + }, + ) } + @Throws(Throwable::class) - fun loginBlocking(userName: String, password: String, twoFactorCode: String?) { + fun loginBlocking( + userName: String, + password: String, + twoFactorCode: String?, + ) { val tokenResponse = getLoginToken().execute() - if (tokenResponse.body()?.query()?.loginToken().isNullOrEmpty()) { + if (tokenResponse + .body() + ?.query() + ?.loginToken() + .isNullOrEmpty() + ) { throw IOException("Unexpected response when getting login token.") } val loginToken = tokenResponse.body()?.query()?.loginToken() - val tempLoginCall = if (twoFactorCode.isNullOrEmpty()) { - loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) - } else { - loginInterface.postLogIn( - userName, password, null, twoFactorCode, loginToken, userLanguage, true - ) - } + val tempLoginCall = + if (twoFactorCode.isNullOrEmpty()) { + loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) + } else { + loginInterface.postLogIn( + userName, + password, + null, + twoFactorCode, + loginToken, + userLanguage, + true, + ) + } val response = tempLoginCall.execute() val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.") @@ -166,18 +226,23 @@ class LoginClient(private val loginInterface: LoginInterface) { } } - private fun getExtendedInfo(userName: String, loginResult: LoginResult, cb: LoginCallback) = - loginInterface.getUserInfo(userName) - .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response: MwQueryResponse? -> - loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 - loginResult.groups = - response?.query()?.getUserResponse(userName)?.groups ?: emptySet() - cb.success(loginResult) - }, { caught: Throwable -> - Timber.e(caught, "Login succeeded but getting group information failed. ") - cb.error(caught) - }) + private fun getExtendedInfo( + userName: String, + loginResult: LoginResult, + cb: LoginCallback, + ) = loginInterface + .getUserInfo(userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response: MwQueryResponse? -> + loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 + loginResult.groups = + response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() + cb.success(loginResult) + }, { caught: Throwable -> + Timber.e(caught, "Login succeeded but getting group information failed. ") + cb.error(caught) + }) fun cancel() { tokenCall?.let { diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt index 2f60b2071..fb5ad14c6 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginFailedException.kt @@ -1,3 +1,5 @@ package fr.free.nrw.commons.auth.login -class LoginFailedException(message: String?) : Throwable(message) +class LoginFailedException( + message: String?, +) : Throwable(message) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt index 0502cf8c7..07e1cd45c 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt @@ -1,8 +1,8 @@ package fr.free.nrw.commons.auth.login import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX -import io.reactivex.Observable import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Observable import retrofit2.Call import retrofit2.http.Field import retrofit2.http.FormUrlEncoded @@ -24,7 +24,7 @@ interface LoginInterface { @Field("password") pass: String?, @Field("logintoken") token: String?, @Field("uselang") userLanguage: String?, - @Field("loginreturnurl") url: String? + @Field("loginreturnurl") url: String?, ): Call @Headers("Cache-Control: no-cache") @@ -37,9 +37,11 @@ interface LoginInterface { @Field("OATHToken") twoFactorCode: String?, @Field("logintoken") token: String?, @Field("uselang") userLanguage: String?, - @Field("logincontinue") loginContinue: Boolean + @Field("logincontinue") loginContinue: Boolean, ): Call @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") - fun getUserInfo(@Query("ususers") userName: String): Observable -} \ No newline at end of file + fun getUserInfo( + @Query("ususers") userName: String, + ): Observable +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt index 7e3a80c9e..a96778e38 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt @@ -13,9 +13,7 @@ class LoginResponse { @SerializedName("clientlogin") private val clientLogin: ClientLogin? = null - fun toLoginResult(password: String): LoginResult? { - return clientLogin?.toLoginResult(password) - } + fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password) } internal class ClientLogin { @@ -39,7 +37,7 @@ internal class ClientLogin { } } } else if ("PASS" != status && "FAIL" != status) { - //TODO: String resource -- Looks like needed for others in this class too + // TODO: String resource -- Looks like needed for others in this class too userMessage = "An unknown error occurred." } return Result(status ?: "", userName, password, userMessage) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt index 69e4a7f89..6a7594ec0 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt @@ -4,7 +4,7 @@ sealed class LoginResult( val status: String, val userName: String?, val password: String?, - val message: String? + val message: String?, ) { var userId = 0 var groups = emptySet() @@ -14,20 +14,20 @@ sealed class LoginResult( status: String, userName: String?, password: String?, - message: String? - ): LoginResult(status, userName, password, message) + message: String?, + ) : LoginResult(status, userName, password, message) class OAuthResult( status: String, userName: String?, password: String?, - message: String? + message: String?, ) : LoginResult(status, userName, password, message) class ResetPasswordResult( status: String, userName: String?, password: String?, - message: String? + message: String?, ) : LoginResult(status, userName, password, message) } diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java index 281248ca4..ca7dd3f3b 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java @@ -2,18 +2,17 @@ package fr.free.nrw.commons.bookmarks; import android.content.Context; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; -import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment; import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; @@ -26,6 +25,7 @@ import fr.free.nrw.commons.media.MediaDetailPagerFragment; import fr.free.nrw.commons.navtab.NavTab; import java.util.ArrayList; import java.util.Iterator; +import timber.log.Timber; public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements FragmentManager.OnBackStackChangedListener, @@ -48,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple String title = bundle.getString("categoryName"); int order = bundle.getInt("order"); final int orderItem = bundle.getInt("orderItem"); - if (order == 0) { - listFragment = new BookmarkPicturesFragment(); - } else { - listFragment = new BookmarkLocationsFragment(); + + switch (order){ + case 0: listFragment = new BookmarkPicturesFragment(); + break; + + case 1: listFragment = new BookmarkLocationsFragment(); + break; + + case 3: listFragment = new BookmarkCategoriesFragment(); + break; + } if(orderItem == 2) { listFragment = new BookmarkItemsFragment(); } - } + Bundle featuredArguments = new Bundle(); featuredArguments.putString("categoryName", title); listFragment.setArguments(featuredArguments); @@ -129,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple @Override public void onMediaClicked(int position) { - Log.d("deneme8", "on media clicked"); + Timber.d("on media clicked"); /*container.setVisibility(View.VISIBLE); ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); mediaDetails = new MediaDetailPagerFragment(false, true, position); @@ -237,7 +244,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple @Override public void onItemClick(AdapterView parent, View view, int position, long id) { - Log.d("deneme8", "on media clicked"); + Timber.d("on media clicked"); binding.exploreContainer.setVisibility(View.VISIBLE); ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE); mediaDetails = MediaDetailPagerFragment.newInstance(false, true); diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java deleted file mode 100644 index 71690c5e2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt new file mode 100644 index 000000000..e0ade52fe --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java index ea3a9a453..f0620032a 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java @@ -49,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter { new BookmarkListRootFragment(locationBundle, this), context.getString(R.string.title_page_bookmarks_items))); } + final Bundle categoriesBundle = new Bundle(); + categoriesBundle.putString("categoryName", + context.getString(R.string.title_page_bookmarks_categories)); + categoriesBundle.putInt("order", 3); + pages.add(new BookmarkPages( + new BookmarkListRootFragment(categoriesBundle, this), + context.getString(R.string.title_page_bookmarks_categories))); notifyDataSetChanged(); } diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt new file mode 100644 index 000000000..71a2d1ec9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt @@ -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> + +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt new file mode 100644 index 000000000..ef5bc613d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt @@ -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" + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt new file mode 100644 index 000000000..ab679611f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt @@ -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 +) diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt index 64bdf5315..4233d9508 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt @@ -15,25 +15,34 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem /** * Helps to inflate Wikidata Items into Items tab */ -class BookmarkItemsAdapter (val list: List, val context: Context) : - RecyclerView.Adapter() { - - class BookmarkItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - +class BookmarkItemsAdapter( + val list: List, + val context: Context, +) : RecyclerView.Adapter() { + class BookmarkItemViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) var description: TextView = itemView.findViewById(R.id.description) 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 { - val v: View = LayoutInflater.from(context) - .inflate(R.layout.item_depictions, parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BookmarkItemViewHolder { + val v: View = + LayoutInflater + .from(context) + .inflate(R.layout.item_depictions, parent, false) return BookmarkItemViewHolder(v) } - override fun onBindViewHolder(holder: BookmarkItemViewHolder, position: Int) { - + override fun onBindViewHolder( + holder: BookmarkItemViewHolder, + position: Int, + ) { val depictedItem = list[position] holder.depictsLabel.text = depictedItem.name holder.description.text = depictedItem.description @@ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List, val context: Context) } } - override fun getItemCount(): Int { - return list.size - } -} \ No newline at end of file + override fun getItemCount(): Int = list.size +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java index 70c370836..6788a8290 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.items; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -134,6 +135,7 @@ public class BookmarkItemsDao { * @param cursor : Object for storing database data * @return DepictedItem */ + @SuppressLint("Range") DepictedItem fromCursor(final Cursor cursor) { final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); final String description diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java index a55ae5e0d..fe4f603f4 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.locations; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -46,11 +47,11 @@ public class BookmarkLocationsDao { ContentProviderClient db = clientProvider.get(); try { cursor = db.query( - BookmarkLocationsContentProvider.BASE_URI, - Table.ALL_FIELDS, - null, - new String[]{}, - null); + BookmarkLocationsContentProvider.BASE_URI, + Table.ALL_FIELDS, + null, + new String[]{}, + null); while (cursor != null && cursor.moveToNext()) { items.add(fromCursor(cursor)); } @@ -126,11 +127,11 @@ public class BookmarkLocationsDao { ContentProviderClient db = clientProvider.get(); try { cursor = db.query( - BookmarkLocationsContentProvider.BASE_URI, - Table.ALL_FIELDS, - Table.COLUMN_NAME + "=?", - new String[]{bookmarkLocation.name}, - null); + BookmarkLocationsContentProvider.BASE_URI, + Table.ALL_FIELDS, + Table.COLUMN_NAME + "=?", + new String[]{bookmarkLocation.name}, + null); if (cursor != null && cursor.moveToFirst()) { return true; } @@ -146,10 +147,11 @@ public class BookmarkLocationsDao { return false; } + @SuppressLint("Range") @NonNull Place fromCursor(final Cursor cursor) { final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)), - cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F); + cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F); final Sitelinks.Builder builder = new Sitelinks.Builder(); builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK))); @@ -207,40 +209,40 @@ public class BookmarkLocationsDao { // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. public static final String[] ALL_FIELDS = { - COLUMN_NAME, - COLUMN_LANGUAGE, - COLUMN_DESCRIPTION, - COLUMN_CATEGORY, - COLUMN_LABEL_TEXT, - COLUMN_LABEL_ICON, - COLUMN_LAT, - COLUMN_LONG, - COLUMN_IMAGE_URL, - COLUMN_WIKIPEDIA_LINK, - COLUMN_WIKIDATA_LINK, - COLUMN_COMMONS_LINK, - COLUMN_PIC, - COLUMN_EXISTS, + COLUMN_NAME, + COLUMN_LANGUAGE, + COLUMN_DESCRIPTION, + COLUMN_CATEGORY, + COLUMN_LABEL_TEXT, + COLUMN_LABEL_ICON, + COLUMN_LAT, + COLUMN_LONG, + COLUMN_IMAGE_URL, + COLUMN_WIKIPEDIA_LINK, + COLUMN_WIKIDATA_LINK, + COLUMN_COMMONS_LINK, + COLUMN_PIC, + COLUMN_EXISTS, }; static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME; static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" - + COLUMN_NAME + " STRING PRIMARY KEY," - + COLUMN_LANGUAGE + " STRING," - + COLUMN_DESCRIPTION + " STRING," - + COLUMN_CATEGORY + " STRING," - + COLUMN_LABEL_TEXT + " STRING," - + COLUMN_LABEL_ICON + " INTEGER," - + COLUMN_LAT + " DOUBLE," - + COLUMN_LONG + " DOUBLE," - + COLUMN_IMAGE_URL + " STRING," - + COLUMN_WIKIPEDIA_LINK + " STRING," - + COLUMN_WIKIDATA_LINK + " STRING," - + COLUMN_COMMONS_LINK + " STRING," - + COLUMN_PIC + " STRING," - + COLUMN_EXISTS + " STRING" - + ");"; + + COLUMN_NAME + " STRING PRIMARY KEY," + + COLUMN_LANGUAGE + " STRING," + + COLUMN_DESCRIPTION + " STRING," + + COLUMN_CATEGORY + " STRING," + + COLUMN_LABEL_TEXT + " STRING," + + COLUMN_LABEL_ICON + " INTEGER," + + COLUMN_LAT + " DOUBLE," + + COLUMN_LONG + " DOUBLE," + + COLUMN_IMAGE_URL + " STRING," + + COLUMN_WIKIPEDIA_LINK + " STRING," + + COLUMN_WIKIDATA_LINK + " STRING," + + COLUMN_COMMONS_LINK + " STRING," + + COLUMN_PIC + " STRING," + + COLUMN_EXISTS + " STRING" + + ");"; public static void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE_STATEMENT); @@ -308,4 +310,4 @@ public class BookmarkLocationsDao { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java index 65d0e45a8..f5ce556c4 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java @@ -9,6 +9,7 @@ import android.view.ViewGroup; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; @@ -33,6 +34,23 @@ public class BookmarkLocationsFragment extends DaggerFragment { @Inject BookmarkLocationsDao bookmarkLocationDao; @Inject CommonPlaceClickActions commonPlaceClickActions; private PlaceAdapter adapter; + + private final ActivityResultLauncher cameraPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { + contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher galleryPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { + contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); + }); + private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback>() { @Override public void onActivityResult(Map result) { @@ -45,7 +63,7 @@ public class BookmarkLocationsFragment extends DaggerFragment { contributionController.locationPermissionCallback.onLocationPermissionGranted(); } else { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher); + contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } else { 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; }, commonPlaceClickActions, - inAppCameraLocationPermissionLauncher + inAppCameraLocationPermissionLauncher, + galleryPickLauncherForResult, + cameraPickLauncherForResult ); 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 public void onDestroy() { super.onDestroy(); diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt index 541baeb69..a33638e72 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/models/Bookmark.kt @@ -2,25 +2,25 @@ package fr.free.nrw.commons.bookmarks.models import android.net.Uri -class Bookmark(mediaName: String?, mediaCreator: String?, - /** - * Modifies the content URI - marking this bookmark as already saved in the database - * @param contentUri the content URI - */ - var contentUri: Uri?) { +class Bookmark( + mediaName: String?, + mediaCreator: String?, /** - * Gets the content URI for this bookmark + * Gets or Sets the content URI - marking this bookmark as already saved in the database * @return content URI + * @param contentUri the content URI */ + var contentUri: Uri?, +) { /** * Gets the media name * @return the media name */ val mediaName: String = mediaName ?: "" + /** * Gets media creator * @return creator name */ val mediaCreator: String = mediaCreator ?: "" - -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java index a56a39ba2..c214ae996 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.pictures; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -150,6 +151,7 @@ public class BookmarkPicturesDao { return false; } + @SuppressLint("Range") @NonNull Bookmark fromCursor(Cursor cursor) { String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME)); diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt index dd82e181d..6bf0bc0ed 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignConfig.kt @@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName class CampaignConfig { @SerializedName("showOnlyLiveCampaigns") private val showOnlyLiveCampaigns = false + @SerializedName("sortBy") private val sortBy: String? = null -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt index cb3785055..767732eb7 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignResponseDTO.kt @@ -9,7 +9,7 @@ import fr.free.nrw.commons.campaigns.models.Campaign class CampaignResponseDTO { @SerializedName("config") val campaignConfig: CampaignConfig? = null + @SerializedName("campaigns") val campaigns: List? = null - -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java deleted file mode 100644 index 4d1eb33ce..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java +++ /dev/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(); - } - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt new file mode 100644 index 000000000..7a4720177 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt @@ -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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java deleted file mode 100644 index 51c841451..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java +++ /dev/null @@ -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 { - 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 campaigns = okHttpJsonApiClient.getCampaigns(); - campaigns.observeOn(mainThreadScheduler) - .subscribeOn(ioScheduler) - .subscribeWith(new SingleObserver() { - - @Override public void onSubscribe(Disposable d) { - disposable = d; - } - - @Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) { - List 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"); - } - }); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt new file mode 100644 index 000000000..4743e0e54 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -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 { + 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) { + 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? { + 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java deleted file mode 100644 index a1e79cca6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java +++ /dev/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); -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt new file mode 100644 index 000000000..62a19aaac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt @@ -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?) +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt index 006415a13..cd68797e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/models/Campaign.kt @@ -3,9 +3,11 @@ package fr.free.nrw.commons.campaigns.models /** * A data class to hold a campaign */ -data class Campaign(var title: String? = null, - var description: String? = null, - var startDate: String? = null, - var endDate: String? = null, - var link: String? = null, - var isWLMCampaign: Boolean = false) \ No newline at end of file +data class Campaign( + var title: String? = null, + var description: String? = null, + var startDate: String? = null, + var endDate: String? = null, + var link: String? = null, + var isWLMCampaign: Boolean = false, +) diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt index 99448a22b..fd90be95f 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -8,263 +8,327 @@ import fr.free.nrw.commons.utils.StringSortingUtils import io.reactivex.Observable import io.reactivex.functions.Function4 import timber.log.Timber -import java.util.* +import java.util.Calendar +import java.util.Date import javax.inject.Inject /** * The model class for categories in upload */ -class CategoriesModel @Inject constructor( - private val categoryClient: CategoryClient, - private val categoryDao: CategoryDao, - private val gpsCategoryModel: GpsCategoryModel -) { - private val selectedCategories: MutableList = mutableListOf() +class CategoriesModel + @Inject + constructor( + private val categoryClient: CategoryClient, + private val categoryDao: CategoryDao, + private val gpsCategoryModel: GpsCategoryModel, + ) { + private val selectedCategories: MutableList = mutableListOf() - /** - * Existing categories which are selected - */ - private var selectedExistingCategories: MutableList = mutableListOf() + /** + * Existing categories which are selected + */ + private var selectedExistingCategories: MutableList = mutableListOf() - /** - * Returns if the item contains an year - * @param item - * @return - */ - fun containsYear(item: String): Boolean { - //Check for current and previous year to exclude these categories from removal - val now = Calendar.getInstance() - val year = now[Calendar.YEAR] - val yearInString = year.toString() - val prevYear = year - 1 - val prevYearInString = prevYear.toString() - Timber.d("Previous year: %s", prevYearInString) + /** + * Returns true if an item is considered to be a spammy category which should be ignored + * + * @param item a category item that needs to be validated to know if it is spammy or not + * @return + */ + fun isSpammyCategory(item: String): Boolean { + // Check for current and previous year to exclude these categories from removal + val now = Calendar.getInstance() + val curYear = now[Calendar.YEAR] + val curYearInString = curYear.toString() + val prevYear = curYear - 1 + val prevYearInString = prevYear.toString() + Timber.d("Previous year: %s", prevYearInString) - //Check if item contains a 4-digit word anywhere within the string (.* is wildcard) - //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) - //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 - return item.matches(".*(19|20)\\d{2}.*".toRegex()) - && !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()) - } + 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()) - /** - * Updates category count in category dao - * @param item - */ - fun updateCategoryCount(item: CategoryItem) { - var category = categoryDao.find(item.name) + // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) + if (spammyCategory) { + return true + } - // Newly used category... - if (category == null) { - category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) + if (mentionsDecade) { + // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 + // Example: "2020s" is OK, but "1920s" is not (and should be skipped) + return !recentDecade + } else { + // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year + // anywhere within the string (.* is wildcard) (Issue #47) + // And that item does not equal the current year or previous year + return item.matches(".*(19|20)\\d{2}.*".toRegex()) && + !item.contains(curYearInString) && + !item.contains(prevYearInString) + } } - category.incTimesUsed() - categoryDao.save(category) - } - /** - * Regional category search - * @param term - * @param imageTitleList - * @return - */ - fun searchAll( - term: String, - imageTitleList: List, - selectedDepictions: List - ): Observable> { - return suggestionsOrSearch(term, imageTitleList, selectedDepictions) - .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } - } + /** + * Updates category count in category dao + * @param item + */ + fun updateCategoryCount(item: CategoryItem) { + var category = categoryDao.find(item.name) - private fun suggestionsOrSearch( - term: String, - imageTitleList: List, - selectedDepictions: List - ): Observable> { - return if (TextUtils.isEmpty(term)) - Observable.combineLatest( - categoriesFromDepiction(selectedDepictions), - gpsCategoryModel.categoriesFromLocation, - titleCategories(imageTitleList), - Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), - Function4(::combine) - ) - else - categoryClient.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) - .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } - .toObservable() - } - - /** - * Fetches details of every category associated with selected depictions, converts them into - * CategoryItem and returns them in a list. - * - * @param selectedDepictions selected DepictItems - * @return List of CategoryItem associated with selected depictions - */ - private fun categoriesFromDepiction(selectedDepictions: List): - Observable>? { - return Observable.fromIterable( - selectedDepictions.map { it.commonsCategories }.flatten()) - .map { categoryItem -> - categoryClient.getCategoriesByName(categoryItem.name, - categoryItem.name, SEARCH_CATS_LIMIT).map { - - CategoryItem(it[0].name, it[0].description, - it[0].thumbnail, it[0].isSelected) - - }.blockingGet() - }.toList().toObservable() - } - - /** - * Fetches details of every category by their name, converts them into - * CategoryItem and returns them in a list. - * - * @param categoryNames selected Categories - * @return List of CategoryItem - */ - fun getCategoriesByName(categoryNames: List): - Observable>? { - return Observable.fromIterable(categoryNames) - .map { categoryName -> - buildCategories(categoryName) - } - .filter { categoryItem -> - categoryItem.name != "Hidden" - } - .toList().toObservable() - } - - /** - * Fetches the categories and converts them into CategoryItem - */ - fun buildCategories(categoryName: String): CategoryItem { - return categoryClient.getCategoriesByName(categoryName, - categoryName, SEARCH_CATS_LIMIT).map { - if(it.isNotEmpty()) { - CategoryItem( - it[0].name, it[0].description, - it[0].thumbnail, it[0].isSelected - ) - } else { - CategoryItem( - "Hidden", "Hidden", - "hidden", false + // Newly used category... + if (category == null) { + category = Category( + null, item.name, + item.description, + item.thumbnail, + Date(), + 0 ) } - }.blockingGet() - } + category.incTimesUsed() + categoryDao.save(category) + } - private fun combine( - depictionCategories: List, - locationCategories: List, - titles: List, - recents: List - ) = depictionCategories + locationCategories + titles + recents + /** + * Regional category search + * @param term + * @param imageTitleList + * @return + */ + fun searchAll( + term: String, + imageTitleList: List, + selectedDepictions: List, + ): Observable> = + suggestionsOrSearch(term, imageTitleList, selectedDepictions) + .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } - - /** - * Returns title based categories - * @param titleList - * @return - */ - private fun titleCategories(titleList: List) = - if (titleList.isNotEmpty()) - Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> - searchResults.map { it as List }.flatten() - } - else - Observable.just(emptyList()) - - /** - * Return category for single title - * @param title - * @return - */ - private fun getTitleCategories(title: String): Observable> { - return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() - } - - - /** - * Handles category item selection - * @param item - */ - fun onCategoryItemClicked(item: CategoryItem, media: Media?) { - if (media == null) { - if (item.isSelected) { - selectedCategories.add(item) - updateCategoryCount(item) + private fun suggestionsOrSearch( + term: String, + imageTitleList: List, + selectedDepictions: List, + ): Observable> = + if (TextUtils.isEmpty(term)) { + Observable.combineLatest( + categoriesFromDepiction(selectedDepictions), + gpsCategoryModel.categoriesFromLocation, + titleCategories(imageTitleList), + Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), + Function4(::combine), + ) } else { - selectedCategories.remove(item) + categoryClient + .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) + .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } + .toObservable() } - } else { - if (item.isSelected) { - if (media.categories?.contains(item.name) == true) { - selectedExistingCategories.add(item.name) + + /** + * Fetches details of every category associated with selected depictions, converts them into + * CategoryItem and returns them in a list. + * If a selected depiction has no categories, the categories in which its P18 belongs are + * returned in the list. + * + * @param selectedDepictions selected DepictItems + * @return List of CategoryItem associated with selected depictions + */ + private fun categoriesFromDepiction(selectedDepictions: List): Observable>? { + val observables = selectedDepictions.map { depictedItem -> + if (depictedItem.commonsCategories.isEmpty()) { + if (depictedItem.primaryImage == null) { + return@map Observable.just(emptyList()) + } + Observable.just( + depictedItem.primaryImage + ).map { image -> + categoryClient + .getCategoriesOfImage( + image, + SEARCH_CATS_LIMIT, + ).map { + it.map { category -> + CategoryItem( + category.name, + category.description, + category.thumbnail, + category.isSelected, + ) + } + }.blockingGet() + }.flatMapIterable { it }.toList() + .toObservable() } else { - selectedCategories.add(item) - updateCategoryCount(item) + 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()) { accumulator, currentList -> + accumulator.apply { addAll(currentList) } + } + } + + /** + * Fetches details of every category by their name, converts them into + * CategoryItem and returns them in a list. + * + * @param categoryNames selected Categories + * @return List of CategoryItem + */ + fun getCategoriesByName(categoryNames: List): Observable>? = + Observable + .fromIterable(categoryNames) + .map { categoryName -> + buildCategories(categoryName) + }.filter { categoryItem -> + categoryItem.name != "Hidden" + }.toList() + .toObservable() + + /** + * Fetches the categories and converts them into CategoryItem + */ + fun buildCategories(categoryName: String): CategoryItem = + categoryClient + .getCategoriesByName( + categoryName, + categoryName, + SEARCH_CATS_LIMIT, + ).map { + if (it.isNotEmpty()) { + CategoryItem( + it[0].name, + it[0].description, + it[0].thumbnail, + it[0].isSelected, + ) + } else { + CategoryItem( + "Hidden", + "Hidden", + "hidden", + false, + ) + } + }.blockingGet() + + private fun combine( + depictionCategories: List, + locationCategories: List, + titles: List, + recents: List, + ) = depictionCategories + locationCategories + titles + recents + + /** + * Returns title based categories + * @param titleList + * @return + */ + private fun titleCategories(titleList: List) = + if (titleList.isNotEmpty()) { + Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> + searchResults.map { it as List }.flatten() } } else { - if (media.categories?.contains(item.name) == true) { - selectedExistingCategories.remove(item.name) - if (!media.categories?.contains(item.name)!!) { - val categoriesList: MutableList = ArrayList() - categoriesList.add(item.name) - categoriesList.addAll(media.categories!!) - media.categories = categoriesList - } + Observable.just(emptyList()) + } + + /** + * Return category for single title + * @param title + * @return + */ + private fun getTitleCategories(title: String): Observable> = + categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() + + /** + * Handles category item selection + * @param item + */ + fun onCategoryItemClicked( + item: CategoryItem, + media: Media?, + ) { + if (media == null) { + if (item.isSelected) { + selectedCategories.add(item) + updateCategoryCount(item) } else { selectedCategories.remove(item) } + } else { + if (item.isSelected) { + if (media.categories?.contains(item.name) == true) { + selectedExistingCategories.add(item.name) + } else { + selectedCategories.add(item) + updateCategoryCount(item) + } + } else { + if (media.categories?.contains(item.name) == true) { + selectedExistingCategories.remove(item.name) + if (!media.categories?.contains(item.name)!!) { + val categoriesList: MutableList = ArrayList() + categoriesList.add(item.name) + categoriesList.addAll(media.categories!!) + media.categories = categoriesList + } + } else { + selectedCategories.remove(item) + } + } } } - } - /** - * Get Selected Categories - * @return - */ - fun getSelectedCategories(): List { - return selectedCategories - } + /** + * Get Selected Categories + * @return + */ + fun getSelectedCategories(): List = selectedCategories - /** - * Cleanup the existing in memory cache's - */ - fun cleanUp() { - selectedCategories.clear() - selectedExistingCategories.clear() - } + /** + * Cleanup the existing in memory cache's + */ + fun cleanUp() { + selectedCategories.clear() + selectedExistingCategories.clear() + } - companion object { - const val SEARCH_CATS_LIMIT = 25 - } + companion object { + const val SEARCH_CATS_LIMIT = 25 + } - /** - * Provides selected existing categories - * - * @return selected existing categories - */ - fun getSelectedExistingCategories(): List { - return selectedExistingCategories - } + /** + * Provides selected existing categories + * + * @return selected existing categories + */ + fun getSelectedExistingCategories(): List = selectedExistingCategories - /** - * Initialize existing categories - * - * @param selectedExistingCategories existing categories - */ - fun setSelectedExistingCategories(selectedExistingCategories: MutableList) { - this.selectedExistingCategories = selectedExistingCategories + /** + * Initialize existing categories + * + * @param selectedExistingCategories existing categories + */ + fun setSelectedExistingCategories(selectedExistingCategories: MutableList) { + this.selectedExistingCategories = selectedExistingCategories + } } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.java b/app/src/main/java/fr/free/nrw/commons/category/Category.java deleted file mode 100644 index 32bba67ba..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/Category.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt new file mode 100644 index 000000000..e4bfb957a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt @@ -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++ + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java deleted file mode 100644 index df99b4060..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package fr.free.nrw.commons.category; - -public interface CategoryClickedListener { - void categoryClicked(CategoryItem item); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt new file mode 100644 index 000000000..ef4ec3d39 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface CategoryClickedListener { + fun categoryClicked(item: CategoryItem) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt index 49796b03a..b031f12f1 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons.category -import io.reactivex.Single import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Single import javax.inject.Inject import javax.inject.Singleton @@ -15,109 +15,143 @@ const val CATEGORY_NEEDING_CATEGORIES = "needing categories" * Category Client to handle custom calls to Commons MediaWiki APIs */ @Singleton -class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) : - ContinuationClient() { +class CategoryClient + @Inject + constructor( + private val categoryInterface: CategoryInterface, + ) : ContinuationClient() { + /** + * Searches for categories containing the specified string. + * + * @param filter The string to be searched + * @param itemLimit How many results are returned + * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result + * @return + */ + @JvmOverloads + fun searchCategories( + filter: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) - /** - * Searches for categories containing the specified string. - * - * @param filter The string to be searched - * @param itemLimit How many results are returned - * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result - * @return - */ - @JvmOverloads - fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0): - Single> { - return responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) - } - - /** - * Searches for categories starting with the specified string. - * - * @param prefix The prefix to be searched - * @param itemLimit How many results are returned - * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result - * @return - */ - @JvmOverloads - fun searchCategoriesForPrefix(prefix: String?, itemLimit: Int, offset: Int = 0): - Single> { - return responseMapper( - categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset) - ) - } - - /** - * Fetches categories starting and ending with a specified name. - * - * @param startingCategoryName Name of the category to start - * @param endingCategoryName Name of the category to end - * @param itemLimit How many categories to return - * @param offset offset - * @return MwQueryResponse - */ - @JvmOverloads - fun getCategoriesByName(startingCategoryName: String?, endingCategoryName: String?, - itemLimit: Int, offset: Int = 0): Single> { - return responseMapper( - categoryInterface.getCategoriesByName(startingCategoryName, endingCategoryName, - itemLimit, offset) - ) - } - - /** - * The method takes categoryName as input and returns a List of Subcategories - * It uses the generator query API to get the subcategories in a category, 500 at a time. - * - * @param categoryName Category name as defined on commons - * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. - */ - fun getSubCategoryList(categoryName: String): Single> { - return continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { - categoryInterface.getSubCategoryList( - categoryName, it + /** + * Searches for categories starting with the specified string. + * + * @param prefix The prefix to be searched + * @param itemLimit How many results are returned + * @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result + * @return + */ + @JvmOverloads + fun searchCategoriesForPrefix( + prefix: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = + responseMapper( + categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset), ) - } - } - /** - * The method takes categoryName as input and returns a List of parent categories - * It uses the generator query API to get the parent categories of a category, 500 at a time. - * - * @param categoryName Category name as defined on commons - * @return - */ - fun getParentCategoryList(categoryName: String): Single> { - return continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { - categoryInterface.getParentCategoryList(categoryName, it) - } - } + /** + * Fetches categories starting and ending with a specified name. + * + * @param startingCategoryName Name of the category to start + * @param endingCategoryName Name of the category to end + * @param itemLimit How many categories to return + * @param offset offset + * @return MwQueryResponse + */ + @JvmOverloads + fun getCategoriesByName( + startingCategoryName: String?, + endingCategoryName: String?, + itemLimit: Int, + offset: Int = 0, + ): Single> = + responseMapper( + categoryInterface.getCategoriesByName( + startingCategoryName, + endingCategoryName, + itemLimit, + offset, + ), + ) - fun resetSubCategoryContinuation(category: String) { - resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) - } + /** + * 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> = + responseMapper( + categoryInterface.getCategoriesByTitles( + "File:${image}", + itemLimit, + ), + ) - fun resetParentCategoryContinuation(category: String) { - resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) - } - - override fun responseMapper( - networkResult: Single, - key: String? - ): Single> { - return networkResult - .map { - handleContinuationResponse(it.continuation(), key) - it.query()?.pages() ?: emptyList() + /** + * The method takes categoryName as input and returns a List of Subcategories + * It uses the generator query API to get the subcategories in a category, 500 at a time. + * + * @param categoryName Category name as defined on commons + * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. + */ + fun getSubCategoryList(categoryName: String): Single> = + continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { + categoryInterface.getSubCategoryList( + categoryName, + it, + ) } - .map { - it.filter { - page -> page.categoryInfo() == null || !page.categoryInfo().isHidden + + /** + * The method takes categoryName as input and returns a List of parent categories + * It uses the generator query API to get the parent categories of a category, 500 at a time. + * + * @param categoryName Category name as defined on commons + * @return + */ + fun getParentCategoryList(categoryName: String): Single> = + continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { + categoryInterface.getParentCategoryList(categoryName, it) + } + + fun resetSubCategoryContinuation(category: String) { + resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) + } + + fun resetParentCategoryContinuation(category: String) { + resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) + } + + override fun responseMapper( + networkResult: Single, + key: String?, + ): Single> = + networkResult + .map { + handleContinuationResponse(it.continuation(), key) + it.query()?.pages() ?: emptyList() }.map { - CategoryItem(it.title().replace(CATEGORY_PREFIX, ""), - it.description().toString(), it.thumbUrl().toString(), false) + it + .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, + ) + } } - } } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java deleted file mode 100644 index 01793ca95..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java +++ /dev/null @@ -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; - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt new file mode 100644 index 000000000..ddd7f5ae4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt @@ -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?, selection: String?, + selectionArgs: Array?, 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?): Int { + // Not implemented + return 0 + } + + @SuppressWarnings("ConstantConditions") + override fun bulkInsert(uri: Uri, values: Array): 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?): 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}") + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java deleted file mode 100644 index b638fc508..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java +++ /dev/null @@ -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 clientProvider; - - @Inject - public CategoryDao(@Named("category") Provider 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 recentCategories(int limit) { - List 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; - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt new file mode 100644 index 000000000..3371da184 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt @@ -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 +) { + + 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 { + val items = ArrayList() + 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) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java deleted file mode 100644 index 457bd48c6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java +++ /dev/null @@ -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 fragmentList = new ArrayList<>(); - List 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(); - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt new file mode 100644 index 000000000..a42d26fd6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -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 { 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() + val titleList = mutableListOf() + 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() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt new file mode 100644 index 000000000..a50f25669 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt @@ -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 create(modelClass: Class): T = + if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) { + CategoryDetailsViewModel(bookmarkCategoriesDao) as T + } else { + throw IllegalArgumentException("Unknown class name") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java deleted file mode 100644 index 393a8dba4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java +++ /dev/null @@ -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 makeCategoryEdit(Context context, Media media, List 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 addCategory(Media media, List 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 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 categories); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt new file mode 100644 index 000000000..22cb19172 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt @@ -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, + wikiText: String + ): Single { + 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?, + wikiText: String + ): Observable { + 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?): Boolean + } + + companion object { + const val NOTIFICATION_EDIT_CATEGORY = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java deleted file mode 100644 index 5b85a2f81..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java +++ /dev/null @@ -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); -} - - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt new file mode 100644 index 000000000..9fe811f74 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.category + +interface CategoryImagesCallback { + fun viewPagerNotifyDataSetChanged() + + fun onMediaClicked(position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt index c3c8ae4c7..3888ef889 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryInterface.kt @@ -17,11 +17,13 @@ interface CategoryInterface { * @param itemLimit How many results are returned * @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( @Query("gsrsearch") filter: String?, @Query("gsrlimit") itemLimit: Int, - @Query("gsroffset") offset: Int + @Query("gsroffset") offset: Int, ): Single /** @@ -31,11 +33,13 @@ interface CategoryInterface { * @param itemLimit How many results are returned * @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( @Query("gacprefix") prefix: String?, @Query("gaclimit") itemLimit: Int, - @Query("gacoffset") offset: Int + @Query("gacoffset") offset: Int, ): Single /** @@ -47,23 +51,40 @@ interface CategoryInterface { * @param offset offset * @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( @Query("gacfrom") startingCategory: String?, @Query("gacto") endingCategory: String?, @Query("gaclimit") itemLimit: Int, - @Query("gacoffset") offset: Int + @Query("gacoffset") offset: Int, + ): Single + + /** + * Fetches non-hidden categories by titles. + * + * @param titles titles to fetch categories for (e.g. File:) + * @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 @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") fun getSubCategoryList( @Query("gcmtitle") categoryName: String, - @QueryMap(encoded = true) continuation: Map + @QueryMap(encoded = true) continuation: Map, ): Single @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") fun getParentCategoryList( @Query("titles") categoryName: String?, - @QueryMap(encoded = true) continuation: Map + @QueryMap(encoded = true) continuation: Map, ): Single } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt index 027c9b804..d0ee8d53c 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.kt @@ -4,12 +4,13 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize -data class CategoryItem(val name: String, val description: String?, - val thumbnail: String?, var isSelected: Boolean) : Parcelable { - - override fun toString(): String { - return "CategoryItem: '$name'" - } +data class CategoryItem( + val name: String, + val description: String?, + val thumbnail: String?, + var isSelected: Boolean, +) : Parcelable { + override fun toString(): String = "CategoryItem: '$name'" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -22,7 +23,5 @@ data class CategoryItem(val name: String, val description: String?, return true } - override fun hashCode(): Int { - return name.hashCode() - } + override fun hashCode(): Int = name.hashCode() } diff --git a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt index a85eee79d..0322cd7b6 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/ContinuationClient.kt @@ -2,16 +2,16 @@ package fr.free.nrw.commons.category import io.reactivex.Single - abstract class ContinuationClient { private val continuationStore: MutableMap?> = mutableMapOf() private val continuationExists: MutableMap = mutableMapOf() private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true + fun continuationRequest( prefix: String, name: String, - requestFunction: (Map) -> Single + requestFunction: (Map) -> Single, ): Single> { val key = "$prefix$name" return if (hasMorePagesFor(key)) { @@ -21,9 +21,15 @@ abstract class ContinuationClient { } } - abstract fun responseMapper(networkResult: Single, key: String?=null): Single> + abstract fun responseMapper( + networkResult: Single, + key: String? = null, + ): Single> - fun handleContinuationResponse(continuation:Map?, key:String?){ + fun handleContinuationResponse( + continuation: Map?, + key: String?, + ) { if (key != null) { continuationExists[key] = continuation?.let { continuation -> @@ -33,7 +39,10 @@ abstract class ContinuationClient { } } - protected fun resetContinuation(prefix: String, category: String) { + protected fun resetContinuation( + prefix: String, + category: String, + ) { continuationExists.remove("$prefix$category") continuationStore.remove("$prefix$category") } @@ -44,9 +53,11 @@ abstract class ContinuationClient { * @param prefix * @param userName the username */ - protected fun resetUserContinuation(prefix: String, userName: String) { + protected fun resetUserContinuation( + prefix: String, + userName: String, + ) { continuationExists.remove("$prefix$userName") continuationStore.remove("$prefix$userName") } - } diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java deleted file mode 100644 index af28ad07d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java +++ /dev/null @@ -1,119 +0,0 @@ -package fr.free.nrw.commons.category; - -import android.content.Context; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -import com.facebook.drawee.view.SimpleDraweeView; - -import java.util.ArrayList; -import java.util.List; - -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; - -/** - * This is created to only display UI implementation. Needs to be changed in real implementation - */ - -public class GridViewAdapter extends ArrayAdapter { - private List data; - - public GridViewAdapter(Context context, int layoutResourceId, List data) { - super(context, layoutResourceId, data); - this.data = data; - } - - /** - * Adds more item to the list - * Its triggered on scrolling down in the list - * @param images - */ - public void addItems(List images) { - if (data == null) { - data = new ArrayList<>(); - } - data.addAll(images); - notifyDataSetChanged(); - } - - /** - * Check the first item in the new list with old list and returns true if they are same - * Its triggered on successful response of the fetch images API. - * @param images - */ - public boolean containsAll(List images){ - if (images == null || images.isEmpty()) { - return false; - } - if (data == null) { - data = new ArrayList<>(); - return false; - } - if (data.isEmpty()) { - return false; - } - String fileName = data.get(0).getFilename(); - String imageName = images.get(0).getFilename(); - return imageName.equals(fileName); - } - - @Override - public boolean isEmpty() { - return data == null || data.isEmpty(); - } - - /** - * Sets up the UI for the category image item - * @param position - * @param convertView - * @param parent - * @return - */ - @Override - public View getView(int position, View convertView, ViewGroup parent) { - - if (convertView == null) { - convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_category_images, null); - } - - Media item = data.get(position); - SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView); - TextView fileName = convertView.findViewById(R.id.categoryImageTitle); - TextView uploader = convertView.findViewById(R.id.categoryImageAuthor); - fileName.setText(item.getMostRelevantCaption()); - setUploaderView(item, uploader); - imageView.setImageURI(item.getThumbUrl()); - return convertView; - } - - /** - * @return the Media item at the given position - */ - @Nullable - @Override - public Media getItem(int position) { - return data.get(position); - } - - - /** - * Shows author information if its present - * @param item - * @param uploader - */ - private void setUploaderView(Media item, TextView uploader) { - if (!TextUtils.isEmpty(item.getAuthor())) { - uploader.setVisibility(View.VISIBLE); - uploader.setText(getContext().getString(R.string.image_uploaded_by, item.getUser())); - } else { - uploader.setVisibility(View.GONE); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt new file mode 100644 index 000000000..5dbcc59fd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -0,0 +1,111 @@ +package fr.free.nrw.commons.category + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R + + +/** + * This is created to only display UI implementation. Needs to be changed in real implementation + */ +class GridViewAdapter( + context: Context, + layoutResourceId: Int, + private var data: MutableList? +) : ArrayAdapter(context, layoutResourceId, data ?: mutableListOf()) { + + /** + * Adds more items to the list + * It's triggered on scrolling down in the list + * @param images + */ + fun addItems(images: List) { + if (data == null) { + data = mutableListOf() + } + data?.addAll(images) + notifyDataSetChanged() + } + + /** + * Checks the first item in the new list with the old list and returns true if they are the same + * It's triggered on a successful response of the fetch images API. + * @param images + */ + fun containsAll(images: List?): Boolean { + if (images.isNullOrEmpty()) { + return false + } + if (data.isNullOrEmpty()) { + data = mutableListOf() + return false + } + val fileName = data?.get(0)?.filename + val imageName = images[0].filename + return imageName == fileName + } + + override fun isEmpty(): Boolean { + return data.isNullOrEmpty() + } + + /** + * Sets up the UI for the category image item + * @param position + * @param convertView + * @param parent + * @return + */ + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate( + R.layout.layout_category_images, + parent, + false + ) + + val item = data?.get(position) + val imageView = view.findViewById(R.id.categoryImageView) + val fileName = view.findViewById(R.id.categoryImageTitle) + val uploader = view.findViewById(R.id.categoryImageAuthor) + + item?.let { + fileName.text = it.mostRelevantCaption + setUploaderView(it, uploader) + imageView.setImageURI(it.thumbUrl) + } + + return view + } + + /** + * @return the Media item at the given position + */ + override fun getItem(position: Int): Media? { + return data?.get(position) + } + + /** + * Shows author information if it's present + * @param item + * @param uploader + */ + @SuppressLint("StringFormatInvalid") + private fun setUploaderView(item: Media, uploader: TextView) { + if (!item.author.isNullOrEmpty()) { + uploader.visibility = View.VISIBLE + uploader.text = context.getString( + R.string.image_uploaded_by, + item.user + ) + } else { + uploader.visibility = View.GONE + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java deleted file mode 100644 index 5899d5905..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.category; - -import java.util.List; - -public interface OnCategoriesSaveHandler { - void onCategoriesSave(List categories); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt new file mode 100644 index 000000000..68200992c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface OnCategoriesSaveHandler { + fun onCategoriesSave(categories: List) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java deleted file mode 100644 index c1c8fac18..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.concurrency; - -import androidx.annotation.NonNull; - -import fr.free.nrw.commons.BuildConfig; - -public class BackgroundPoolExceptionHandler implements ExceptionHandler { - /** - * If an exception occurs on a background thread, this handler will crash for debug builds - * but fail silently for release builds. - * @param t - */ - @Override - public void onException(@NonNull final Throwable t) { - //Crash for debug build - if (BuildConfig.DEBUG) { - Thread thread = new Thread(() -> { - throw new RuntimeException(t); - }); - thread.start(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt new file mode 100644 index 000000000..378a98893 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.concurrency + +import fr.free.nrw.commons.BuildConfig + + +class BackgroundPoolExceptionHandler : ExceptionHandler { + /** + * If an exception occurs on a background thread, this handler will crash for debug builds + * but fail silently for release builds. + * @param t + */ + override fun onException(t: Throwable) { + // Crash for debug build + if (BuildConfig.DEBUG) { + val thread = Thread { + throw RuntimeException(t) + } + thread.start() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java deleted file mode 100644 index 80931b1c1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.concurrency; - -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.ThreadFactory; - -class ExceptionAwareThreadPoolExecutor extends ScheduledThreadPoolExecutor { - - private final ExceptionHandler exceptionHandler; - - public ExceptionAwareThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory, - ExceptionHandler exceptionHandler) { - super(corePoolSize, threadFactory); - this.exceptionHandler = exceptionHandler; - } - - @Override - protected void afterExecute(Runnable r, Throwable t) { - super.afterExecute(r, t); - if (t == null && r instanceof Future) { - try { - Future future = (Future) r; - if (future.isDone()) future.get(); - } catch (CancellationException | InterruptedException e) { - //ignore - } catch (ExecutionException e) { - t = e.getCause() != null ? e.getCause() : e; - } catch (Exception e) { - t = e; - } - } - - if (t != null) { - exceptionHandler.onException(t); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt new file mode 100644 index 000000000..0efe057f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt @@ -0,0 +1,40 @@ +package fr.free.nrw.commons.concurrency + +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadFactory + + +class ExceptionAwareThreadPoolExecutor( + corePoolSize: Int, + threadFactory: ThreadFactory, + private val exceptionHandler: ExceptionHandler? +) : ScheduledThreadPoolExecutor(corePoolSize, threadFactory) { + + override fun afterExecute(r: Runnable, t: Throwable?) { + super.afterExecute(r, t) + var throwable = t + + if (throwable == null && r is Future<*>) { + try { + if (r.isDone) { + r.get() + } + } catch (e: CancellationException) { + // ignore + } catch (e: InterruptedException) { + // ignore + } catch (e: ExecutionException) { + throwable = e.cause ?: e + } catch (e: Exception) { + throwable = e + } + } + + throwable?.let { + exceptionHandler?.onException(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java deleted file mode 100644 index 38690305a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.concurrency; - -import androidx.annotation.NonNull; - -public interface ExceptionHandler { - void onException(@NonNull Throwable t); -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt new file mode 100644 index 000000000..6b3d2a0f7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.concurrency + +interface ExceptionHandler { + + fun onException(t: Throwable) + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java deleted file mode 100644 index f057f61b2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java +++ /dev/null @@ -1,124 +0,0 @@ -package fr.free.nrw.commons.concurrency; - -import androidx.annotation.NonNull; - -import java.util.concurrent.Callable; -import java.util.concurrent.Executor; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - -/** - * This class is a thread pool which provides some additional features: - * - it sets the thread priority to a value lower than foreground priority by default, or you can - * supply your own priority - * - it gives you a way to handle exceptions thrown in the thread pool - */ - -public class ThreadPoolService implements Executor { - private final ScheduledThreadPoolExecutor backgroundPool; - - private ThreadPoolService(final Builder b) { - backgroundPool = new ExceptionAwareThreadPoolExecutor(b.poolSize, - new ThreadFactory() { - int count = 0; - @Override - public Thread newThread(@NonNull Runnable r) { - count++; - Thread t = new Thread(r, String.format("%s-%s", b.name, count)); - //If the priority is specified out of range, we set the thread priority to Thread.MIN_PRIORITY - //It's done prevent IllegalArgumentException and to prevent setting of improper high priority for a less priority task - t.setPriority(b.priority > Thread.MAX_PRIORITY || b.priority < Thread.MIN_PRIORITY ? - Thread.MIN_PRIORITY : b.priority); - return t; - } - }, b.exceptionHandler); - } - - public ScheduledFuture schedule(Callable callable, long time, TimeUnit timeUnit) { - return backgroundPool.schedule(callable, time, timeUnit); - } - - public ScheduledFuture schedule(Runnable runnable) { - return schedule(runnable, 0, TimeUnit.SECONDS); - } - - public ScheduledFuture schedule(Runnable runnable, long time, TimeUnit timeUnit) { - return backgroundPool.schedule(runnable, time, timeUnit); - } - - public ScheduledFuture scheduleAtFixedRate(final Runnable task, long initialDelay, - long period, final TimeUnit timeUnit) { - return backgroundPool.scheduleAtFixedRate(task, initialDelay, period, timeUnit); - } - - public ScheduledThreadPoolExecutor executor() { - return backgroundPool; - } - - public void shutdown(){ - backgroundPool.shutdown(); - } - - @Override - public void execute(Runnable command) { - backgroundPool.execute(command); - } - - /** - * Builder class for {@link ThreadPoolService} - */ - public static class Builder { - //Required - private final String name; - - //Optional - private int poolSize = 1; - private int priority = Thread.MIN_PRIORITY; - private ExceptionHandler exceptionHandler = null; - - /** - * @param name the name of the threads in the service. if there are N threads, - * the thread names will be like name-1, name-2, name-3,...,name-N - */ - public Builder(@NonNull String name) { - this.name = name; - } - - /** - * @param poolSize the number of threads to keep in the pool - * @throws IllegalArgumentException if size of pool <=0 - */ - public Builder setPoolSize(int poolSize) throws IllegalArgumentException { - if (poolSize <= 0) { - throw new IllegalArgumentException("Pool size must be grater than 0"); - } - this.poolSize = poolSize; - return this; - } - - /** - * @param priority Priority of the threads in the service. You can supply a constant from - * {@link java.lang.Thread} or - * specify your own priority in the range 1(MIN_PRIORITY) to 10(MAX_PRIORITY) - * By default, the priority is set to {@link java.lang.Thread#MIN_PRIORITY} - */ - public Builder setPriority(int priority) { - this.priority = priority; - return this; - } - - /** - * @param handler The handler to use to handle exceptions in the service - */ - public Builder setExceptionHandler(ExceptionHandler handler) { - this.exceptionHandler = handler; - return this; - } - - public ThreadPoolService build() { - return new ThreadPoolService(this); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt new file mode 100644 index 000000000..46138d676 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt @@ -0,0 +1,122 @@ +package fr.free.nrw.commons.concurrency + +import java.util.concurrent.Callable +import java.util.concurrent.Executor +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit + + +/** + * This class is a thread pool which provides some additional features: + * - it sets the thread priority to a value lower than foreground priority by default, or you can + * supply your own priority + * - it gives you a way to handle exceptions thrown in the thread pool + */ +class ThreadPoolService private constructor(builder: Builder) : Executor { + private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor( + builder.poolSize, + object : ThreadFactory { + private var count = 0 + override fun newThread(r: Runnable): Thread { + count++ + val t = Thread(r, "${builder.name}-$count") + // If the priority is specified out of range, we set the thread priority to + // Thread.MIN_PRIORITY + // It's done to prevent IllegalArgumentException and to prevent setting of + // improper high priority for a less priority task + t.priority = + if ( + builder.priority > Thread.MAX_PRIORITY + || + builder.priority < Thread.MIN_PRIORITY + ) { + Thread.MIN_PRIORITY + } else { + builder.priority + } + return t + } + }, + builder.exceptionHandler + ) + + fun schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture { + return backgroundPool.schedule(callable, time, timeUnit) + } + + fun schedule(runnable: Runnable): ScheduledFuture<*> { + return schedule(runnable, 0, TimeUnit.SECONDS) + } + + fun schedule(runnable: Runnable, time: Long, timeUnit: TimeUnit): ScheduledFuture<*> { + return backgroundPool.schedule(runnable, time, timeUnit) + } + + fun scheduleAtFixedRate( + task: Runnable, + initialDelay: Long, + period: Long, + timeUnit: TimeUnit + ): ScheduledFuture<*> { + return backgroundPool.scheduleWithFixedDelay(task, initialDelay, period, timeUnit) + } + + fun executor(): ScheduledThreadPoolExecutor { + return backgroundPool + } + + fun shutdown() { + backgroundPool.shutdown() + } + + override fun execute(command: Runnable) { + backgroundPool.execute(command) + } + + /** + * Builder class for [ThreadPoolService] + */ + class Builder(val name: String) { + var poolSize: Int = 1 + var priority: Int = Thread.MIN_PRIORITY + var exceptionHandler: ExceptionHandler? = null + + /** + * @param poolSize the number of threads to keep in the pool + * @throws IllegalArgumentException if size of pool <= 0 + */ + fun setPoolSize(poolSize: Int): Builder { + if (poolSize <= 0) { + throw IllegalArgumentException("Pool size must be greater than 0") + } + this.poolSize = poolSize + return this + } + + /** + * @param priority Priority of the threads in the service. You can supply a constant from + * [java.lang.Thread] or + * specify your own priority in the range 1(MIN_PRIORITY) + * to 10(MAX_PRIORITY) + * By default, the priority is set to [java.lang.Thread.MIN_PRIORITY] + */ + fun setPriority(priority: Int): Builder { + this.priority = priority + return this + } + + /** + * @param handler The handler to use to handle exceptions in the service + */ + fun setExceptionHandler(handler: ExceptionHandler): Builder { + exceptionHandler = handler + return this + } + + fun build(): ThreadPoolService { + return ThreadPoolService(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt index 0ef4066a2..b611574b0 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt @@ -7,32 +7,29 @@ import fr.free.nrw.commons.upload.UploadResult data class ChunkInfo( val uploadResult: UploadResult?, val indexOfNextChunkToUpload: Int, - val totalChunks: Int + val totalChunks: Int, ) : Parcelable { constructor(parcel: Parcel) : this( parcel.readParcelable(UploadResult::class.java.classLoader), parcel.readInt(), - parcel.readInt() + parcel.readInt(), ) { } - override fun writeToParcel(parcel: Parcel, flags: Int) { + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { parcel.writeParcelable(uploadResult, flags) parcel.writeInt(indexOfNextChunkToUpload) parcel.writeInt(totalChunks) } - override fun describeContents(): Int { - return 0 - } + override fun describeContents(): Int = 0 companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): ChunkInfo { - return ChunkInfo(parcel) - } + override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel) - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } + override fun newArray(size: Int): Array = arrayOfNulls(size) } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt index be763fe8a..d623730ab 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt @@ -5,7 +5,6 @@ import android.os.Parcelable import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey -import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.Media import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.upload.UploadItem @@ -28,10 +27,10 @@ data class Contribution constructor( var dateCreatedSource: String? = null, var wikidataPlace: WikidataPlace? = null, var chunkInfo: ChunkInfo? = null, + var errorInfo: String? = null, /** * @return array list of entityids for the depictions - */ - /** + * * Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id * is in turn used to upload depictions to wikibase */ @@ -42,43 +41,41 @@ data class Contribution constructor( var dateCreated: Date? = null, var dateCreatedString: String? = null, var dateModified: Date? = null, - var hasInvalidLocation : Int = 0, + var dateUploadStarted: Date? = null, + var hasInvalidLocation: Int = 0, var contentUri: Uri? = null, - var countryCode : String? = null, - var imageSHA1 : String? = null, + var countryCode: String? = null, + var imageSHA1: String? = null, /** * Number of times a contribution has been retried after a failure */ - var retries: Int = 0 + var retries: Int = 0, ) : Parcelable { - - fun completeWith(media: Media): Contribution { - return copy(pageId = media.pageId, media = media, state = STATE_COMPLETED) - } + fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED) constructor( item: UploadItem, sessionManager: SessionManager, depictedItems: List, categories: List, - imageSHA1: String + imageSHA1: String, ) : this( Media( formatCaptions(item.uploadMediaDetails), categories, - item.fileName, + item.filename, formatDescriptions(item.uploadMediaDetails), sessionManager.userName, - sessionManager.userName + sessionManager.userName, ), localUri = item.mediaUri, - decimalCoords = item.gpsCoords.decimalCoords, + decimalCoords = item.gpsCoords?.decimalCoords, dateCreatedSource = "", depictedItems = depictedItems, wikidataPlace = from(item.place), contentUri = item.contentUri, dateCreatedString = item.fileCreatedDateString, - imageSHA1 = imageSHA1 + imageSHA1 = imageSHA1, ) /** @@ -89,9 +86,7 @@ data class Contribution constructor( this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0 } - fun hasInvalidLocation(): Boolean { - return hasInvalidLocation == 1 - } + fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1 companion object { const val STATE_COMPLETED = -1 @@ -99,14 +94,14 @@ data class Contribution constructor( const val STATE_QUEUED = 2 const val STATE_IN_PROGRESS = 3 const val STATE_PAUSED = 4 - const val STATE_QUEUED_LIMITED_CONNECTION_MODE=5 /** * Formatting captions to the Wikibase format for sending labels * @param uploadMediaDetails list of media Details */ fun formatCaptions(uploadMediaDetails: List) = - uploadMediaDetails.associate { it.languageCode!! to it.captionText } + uploadMediaDetails + .associate { it.languageCode!! to it.captionText } .filter { it.value.isNotBlank() } /** @@ -116,22 +111,15 @@ data class Contribution constructor( * @return a string with the pattern of {{en|1=descriptionText}} */ fun formatDescriptions(descriptions: List) = - descriptions.filter { it.descriptionText.isNotEmpty() } + descriptions + .filter { !it.descriptionText.isNullOrEmpty() } .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" } } - val fileKey : String? get() = chunkInfo?.uploadResult?.filekey + val fileKey: String? get() = chunkInfo?.uploadResult?.filekey val localUriPath: File? get() = localUri?.path?.let { File(it) } - fun isCompleted(): Boolean { - return chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload - } + fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload - fun isPaused(): Boolean { - return CommonsApplication.pauseUploads[pageId] ?: false - } - - fun unpause() { - CommonsApplication.pauseUploads[pageId] = false - } + fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt index b75332b73..b5075a21e 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt @@ -2,7 +2,7 @@ package fr.free.nrw.commons.contributions import androidx.paging.PagedList.BoundaryCallback import fr.free.nrw.commons.auth.SessionManager -import fr.free.nrw.commons.di.CommonsApplicationModule +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD import fr.free.nrw.commons.media.MediaClient import io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable @@ -14,88 +14,109 @@ import javax.inject.Named * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that * is triggered for various boundary conditions in the list */ -class ContributionBoundaryCallback @Inject constructor( - private val repository: ContributionsRepository, - private val sessionManager: SessionManager, - private val mediaClient: MediaClient, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler -) : BoundaryCallback() { - private val compositeDisposable: CompositeDisposable = CompositeDisposable() - var userName: String? = null +class ContributionBoundaryCallback + @Inject + constructor( + private val repository: ContributionsRepository, + private val sessionManager: SessionManager, + private val mediaClient: MediaClient, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, + ) : BoundaryCallback() { + private val compositeDisposable: CompositeDisposable = CompositeDisposable() + var userName: String? = null - - /** - * It is triggered when the list has no items User's Contributions are then fetched from the - * network - */ - override fun onZeroItemsLoaded() { - if (sessionManager.userName != null) { - mediaClient.resetUserNameContinuation(sessionManager.userName!!) + /** + * It is triggered when the list has no items User's Contributions are then fetched from the + * network + */ + override fun onZeroItemsLoaded() { + refreshList() } - fetchContributions() - } - /** - * It is triggered when the user scrolls to the top of the list - * */ - override fun onItemAtFrontLoaded(itemAtFront: Contribution) { + /** + * It is triggered when the user scrolls to the top of the list + * */ + override fun onItemAtFrontLoaded(itemAtFront: Contribution) { + } - } + /** + * It is triggered when the user scrolls to the end of the list. User's Contributions are then + * fetched from the network + */ + override fun onItemAtEndLoaded(itemAtEnd: Contribution) { + fetchContributions() + } - /** - * It is triggered when the user scrolls to the end of the list. User's Contributions are then - * fetched from the network - */ - override fun onItemAtEndLoaded(itemAtEnd: Contribution) { - fetchContributions() - } + /** + * Fetch list from network and save it to local DB. + * + * @param onRefreshFinish callback to invoke when operations finishes + * with either error or success. + */ + fun refreshList(onRefreshFinish: () -> Unit = {}){ + if (sessionManager.userName != null) { + mediaClient.resetUserNameContinuation(sessionManager.userName!!) + } + fetchContributions(onRefreshFinish) + } - /** - * Fetches contributions using the MediaWiki API - */ - private fun fetchContributions() { - if (sessionManager.userName != null) { - userName?.let { userName -> - mediaClient.getMediaListForUser(userName) - .map { mediaList -> - mediaList.map { media -> - Contribution(media = media, state = Contribution.STATE_COMPLETED) - } - } - .subscribeOn(ioThreadScheduler) - .subscribe(::saveContributionsToDB) { error: Throwable -> - Timber.e( - "Failed to fetch contributions: %s", - error.message + /** + * Fetches contributions using the MediaWiki API + * + * @param onRefreshFinish callback to invoke when operations finishes + * with either error or success. + */ + private fun fetchContributions(onRefreshFinish: () -> Unit = {}) { + if (sessionManager.userName != null) { + userName + ?.let { userName -> + mediaClient + .getMediaListForUser(userName) + .map { mediaList -> + mediaList.map { media -> + Contribution(media = media, state = Contribution.STATE_COMPLETED) + } + }.subscribeOn(ioThreadScheduler) + .subscribe({ list -> + saveContributionsToDB(list, onRefreshFinish) + },{ error -> + onRefreshFinish() + Timber.e( + "Failed to fetch contributions: %s", + error.message, + ) + }) + }?.let { + compositeDisposable.add( + it, ) } - }?.let { - compositeDisposable.add( - it - ) + } else { + compositeDisposable.clear() } - }else { - compositeDisposable.clear() + } + + /** + * Saves the contributions the the local DB + * + * @param onRefreshFinish callback to invoke when successfully saved to DB. + */ + private fun saveContributionsToDB(contributions: List, onRefreshFinish: () -> Unit) { + compositeDisposable.add( + repository + .save(contributions) + .subscribeOn(ioThreadScheduler) + .subscribe { longs: List? -> + onRefreshFinish() + repository["last_fetch_timestamp"] = System.currentTimeMillis() + }, + ) + } + + /** + * Clean up + */ + fun dispose() { + compositeDisposable.dispose() } } - - /** - * Saves the contributions the the local DB - */ - private fun saveContributionsToDB(contributions: List) { - compositeDisposable.add( - repository.save(contributions) - .subscribeOn(ioThreadScheduler) - .subscribe { longs: List? -> - repository["last_fetch_timestamp"] = System.currentTimeMillis() - } - ) - } - - /** - * Clean up - */ - fun dispose() { - compositeDisposable.dispose() - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index 7c48bb2b0..65604a7e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -7,8 +7,13 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; import android.widget.Toast; +import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.paging.DataSource.Factory; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; import fr.free.nrw.commons.R; import fr.free.nrw.commons.filepicker.DefaultCallback; import fr.free.nrw.commons.filepicker.FilePicker; @@ -25,6 +30,8 @@ import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.ViewUtil; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import javax.inject.Inject; import javax.inject.Named; @@ -39,10 +46,17 @@ public class ContributionController { private boolean isInAppCameraUpload; public LocationPermissionCallback locationPermissionCallback; private LocationPermissionsHelper locationPermissionsHelper; + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // LiveData> failedAndPendingContributionList; + LiveData> pendingContributionList; + LiveData> failedContributionList; @Inject LocationServiceManager locationManager; + @Inject + ContributionsRepository repository; + @Inject public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) { this.defaultKvStore = defaultKvStore; @@ -52,10 +66,11 @@ public class ContributionController { * Check for permissions and initiate camera click */ public void initiateCameraPick(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); if (!useExtStorage) { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); return; } @@ -63,17 +78,17 @@ public class ContributionController { () -> { if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { defaultKvStore.putBoolean("inAppCameraFirstRun", false); - askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher); + askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher); } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); } else { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } }, R.string.storage_permission_title, R.string.write_storage_permission_rationale, - PermissionUtils.PERMISSIONS_STORAGE); + PermissionUtils.getPERMISSIONS_STORAGE()); } /** @@ -82,7 +97,8 @@ public class ContributionController { * @param activity */ private void createDialogsAndHandleLocationPermissions(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { locationPermissionCallback = new LocationPermissionCallback() { @Override public void onLocationPermissionDenied(String toastMessage) { @@ -91,16 +107,16 @@ public class ContributionController { toastMessage, Toast.LENGTH_LONG ).show(); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } @Override public void onLocationPermissionGranted() { if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { showLocationOffDialog(activity, R.string.in_app_camera_needs_location, - R.string.in_app_camera_location_unavailable); + R.string.in_app_camera_location_unavailable, resultLauncher); } else { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } } }; @@ -115,17 +131,18 @@ public class ContributionController { } /** - * Shows a dialog alerting the user about location services being off - * and asking them to turn it on + * Shows a dialog alerting the user about location services being off and asking them to turn it + * on * TODO: Add a seperate callback in LocationPermissionsHelper for this. * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 * - * @param activity Activity reference + * @param activity Activity reference * @param dialogTextResource Resource id of text to be shown in dialog - * @param toastTextResource Resource id of text to be shown in toast + * @param toastTextResource Resource id of text to be shown in toast + * @param resultLauncher */ private void showLocationOffDialog(Activity activity, int dialogTextResource, - int toastTextResource) { + int toastTextResource, ActivityResultLauncher resultLauncher) { DialogUtil .showAlertDialog(activity, activity.getString(R.string.ask_to_turn_location_on), @@ -136,25 +153,26 @@ public class ContributionController { () -> { Toast.makeText(activity, activity.getString(toastTextResource), Toast.LENGTH_LONG).show(); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } ); } public void handleShowRationaleFlowCameraLocation(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), activity.getString(R.string.in_app_camera_location_permission_rationale), activity.getString(android.R.string.ok), activity.getString(android.R.string.cancel), () -> { createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); }, () -> locationPermissionCallback.onLocationPermissionDenied( activity.getString(R.string.in_app_camera_location_permission_denied)), - null, - false); + null + ); } /** @@ -169,7 +187,8 @@ public class ContributionController { * @param activity */ private void askUserToAllowLocationAccess(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { DialogUtil.showAlertDialog(activity, activity.getString(R.string.in_app_camera_location_permission_title), activity.getString(R.string.in_app_camera_location_access_explanation), @@ -178,47 +197,45 @@ public class ContributionController { () -> { defaultKvStore.putBoolean("inAppCameraLocationPref", true); createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); }, () -> { ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied); defaultKvStore.putBoolean("inAppCameraLocationPref", false); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); }, - null, - true); + null + ); } /** * Initiate gallery picker */ - public void initiateGalleryPick(final Activity activity, final boolean allowMultipleUploads) { - initiateGalleryUpload(activity, allowMultipleUploads); + public void initiateGalleryPick(final Activity activity, ActivityResultLauncher resultLauncher, final boolean allowMultipleUploads) { + initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads); } /** * Initiate gallery picker with permission */ - public void initiateCustomGalleryPickWithPermission(final Activity activity) { + public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher resultLauncher) { setPickerConfiguration(activity, true); PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> FilePicker.openCustomSelector(activity, 0), + () -> FilePicker.openCustomSelector(activity, resultLauncher, 0), R.string.storage_permission_title, R.string.write_storage_permission_rationale, - PermissionUtils.PERMISSIONS_STORAGE); + PermissionUtils.getPERMISSIONS_STORAGE()); } /** * Open chooser for gallery uploads */ - private void initiateGalleryUpload(final Activity activity, + private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher resultLauncher, final boolean allowMultipleUploads) { setPickerConfiguration(activity, allowMultipleUploads); - boolean openDocumentIntentPreferred = defaultKvStore.getBoolean( - "openDocumentPhotoPickerPref", true); - FilePicker.openGallery(activity, 0, openDocumentIntentPreferred); + FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred()); } /** @@ -235,22 +252,43 @@ public class ContributionController { /** * Initiate camera upload by opening camera */ - private void initiateCameraUpload(Activity activity) { + private void initiateCameraUpload(Activity activity, ActivityResultLauncher resultLauncher) { setPickerConfiguration(activity, false); if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { locationBeforeImageCapture = locationManager.getLastLocation(); } isInAppCameraUpload = true; - FilePicker.openCameraForImage(activity, 0); + FilePicker.openCameraForImage(activity, resultLauncher, 0); + } + + private boolean isDocumentPhotoPickerPreferred(){ + return defaultKvStore.getBoolean( + "openDocumentPhotoPickerPref", true); + } + + public void onPictureReturnedFromGallery(ActivityResult result, Activity activity, FilePicker.Callbacks callbacks){ + + if(isDocumentPhotoPickerPreferred()){ + FilePicker.onPictureReturnedFromDocuments(result, activity, callbacks); + } else { + FilePicker.onPictureReturnedFromGallery(result, activity, callbacks); + } + } + + public void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { + FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks); + } + + public void onPictureReturnedFromCamera(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { + FilePicker.onPictureReturnedFromCamera(result, activity, callbacks); } /** * Attaches callback for file picker. */ - public void handleActivityResult(Activity activity, int requestCode, int resultCode, - Intent data) { - FilePicker.handleActivityResult(requestCode, resultCode, data, activity, - new DefaultCallback() { + public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) { + + handleActivityResult.onHandleActivityResult(new DefaultCallback() { @Override public void onCanceled(final ImageSource source, final int type) { @@ -307,4 +345,61 @@ public class ContributionController { isInAppCameraUpload = false; // reset the flag for next use return shareIntent; } + + /** + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it + * populates the `pendingContributionList`. + **/ + void getPendingContributions() { + final PagedList.Config pagedListConfig = + (new PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build(); + Factory factory; + factory = repository.fetchContributionsWithStates( + Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, + Contribution.STATE_PAUSED)); + + LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, + pagedListConfig); + pendingContributionList = livePagedListBuilder.build(); + } + + /** + * Fetches the contributions with the state "FAILED" and populates the + * `failedContributionList`. + **/ + void getFailedContributions() { + final PagedList.Config pagedListConfig = + (new PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build(); + Factory factory; + factory = repository.fetchContributionsWithStates( + Collections.singletonList(Contribution.STATE_FAILED)); + + LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, + pagedListConfig); + failedContributionList = livePagedListBuilder.build(); + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and + * then it populates the `failedAndPendingContributionList`. + **/ +// void getFailedAndPendingContributions() { +// final PagedList.Config pagedListConfig = +// (new PagedList.Config.Builder()) +// .setPrefetchDistance(50) +// .setPageSize(10).build(); +// Factory factory; +// factory = repository.fetchContributionsWithStates( +// Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, +// Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); +// +// LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, +// pagedListConfig); +// failedAndPendingContributionList = livePagedListBuilder.build(); +// } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java index b4889b6a2..2e375145c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java @@ -13,6 +13,7 @@ import io.reactivex.Completable; import io.reactivex.Single; import java.util.Calendar; import java.util.List; +import timber.log.Timber; @Dao public abstract class ContributionDao { @@ -27,6 +28,9 @@ public abstract class ContributionDao { return Completable .fromAction(() -> { contribution.setDateModified(Calendar.getInstance().getTime()); + if (contribution.getDateUploadStarted() == null) { + contribution.setDateUploadStarted(Calendar.getInstance().getTime()); + } saveSynchronous(contribution); }); } @@ -44,11 +48,32 @@ public abstract class ContributionDao { @Delete public abstract void deleteSynchronous(Contribution contribution); + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @throws SQLiteException If an SQLite error occurs. + */ + @Query("DELETE FROM contribution WHERE state IN (:states)") + public abstract void deleteContributionsWithStatesSynchronous(List states) + throws SQLiteException; + public Completable delete(final Contribution contribution) { return Completable .fromAction(() -> deleteSynchronous(contribution)); } + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + public Completable deleteContributionsWithStates(List states) { + return Completable + .fromAction(() -> deleteContributionsWithStatesSynchronous(states)); + } + @Query("SELECT * from contribution WHERE media_filename=:fileName") public abstract List getContributionWithTitle(String fileName); @@ -58,6 +83,26 @@ public abstract class ContributionDao { @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") public abstract Single> getContribution(List states); + /** + * Gets contributions with specific states in descending order by the date they were uploaded. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + public abstract DataSource.Factory getContributions( + List states); + + /** + * Gets contributions with specific states in ascending order by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC") + public abstract DataSource.Factory getContributionsSortedByDateUploadStarted( + List states); + @Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)") public abstract Single getPendingUploads(int[] toUpdateStates); @@ -67,6 +112,15 @@ public abstract class ContributionDao { @Update public abstract void updateSynchronous(Contribution contribution); + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + */ + @Query("UPDATE contribution SET state = :newState WHERE state IN (:states)") + public abstract void updateContributionsState(List states, int newState); + public Completable update(final Contribution contribution) { return Completable .fromAction(() -> { @@ -74,4 +128,18 @@ public abstract class ContributionDao { updateSynchronous(contribution); }); } + + /** + * Updates the state of contributions with specific states asynchronously. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + public Completable updateContributionsWithStates(List states, int newState) { + return Completable + .fromAction(() -> { + updateContributionsState(states, newState); + }); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java index 7ea5163bb..568ac9a37 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java @@ -48,11 +48,8 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { binding = LayoutContributionBinding.bind(parent); - binding.retryButton.setOnClickListener(v -> retryUpload()); - binding.cancelButton.setOnClickListener(v -> deleteUpload()); binding.contributionImage.setOnClickListener(v -> imageClicked()); binding.wikipediaButton.setOnClickListener(v -> wikipediaButtonClicked()); - binding.pauseResumeButton.setOnClickListener(v -> onPauseResumeButtonClicked()); /* Set a dialog indicating that the upload is being paused. This is needed because pausing an upload might take a dozen seconds. */ @@ -79,9 +76,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { binding.contributionImage.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); binding.contributionImage.getHierarchy().setFailureImage(R.drawable.image_placeholder); - - - final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(), contribution.getLocalUri()); @@ -90,79 +84,27 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) .setProgressiveRenderingEnabled(true) .build(); - } - else if (URLUtil.isFileUrl(imageSource)){ - imageRequest=ImageRequest.fromUri(Uri.parse(imageSource)); - } - else if(imageSource != null) { + } else if (URLUtil.isFileUrl(imageSource)) { + imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)); + } else if (imageSource != null) { final File file = new File(imageSource); imageRequest = ImageRequest.fromFile(file); } - if(imageRequest != null){ + if (imageRequest != null) { binding.contributionImage.setImageRequest(imageRequest); } } binding.contributionSequenceNumber.setText(String.valueOf(position + 1)); binding.contributionSequenceNumber.setVisibility(View.VISIBLE); - binding.wikipediaButton.setVisibility(View.GONE); - switch (contribution.getState()) { - case Contribution.STATE_COMPLETED: - binding.contributionState.setVisibility(View.GONE); - binding.contributionProgress.setVisibility(View.GONE); - binding.imageOptions.setVisibility(View.GONE); - binding.contributionState.setText(""); - checkIfMediaExistsOnWikipediaPage(contribution); - break; - case Contribution.STATE_QUEUED: - case Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE: - binding.contributionProgress.setVisibility(View.GONE); - binding.contributionState.setVisibility(View.VISIBLE); - binding.contributionState.setText(R.string.contribution_state_queued); - binding.imageOptions.setVisibility(View.GONE); - break; - case Contribution.STATE_IN_PROGRESS: - binding.contributionState.setVisibility(View.GONE); - binding.contributionProgress.setVisibility(View.VISIBLE); - binding.wikipediaButton.setVisibility(View.GONE); - binding.pauseResumeButton.setVisibility(View.VISIBLE); - binding.cancelButton.setVisibility(View.GONE); - binding.retryButton.setVisibility(View.GONE); - binding.imageOptions.setVisibility(View.VISIBLE); - final long total = contribution.getDataLength(); - final long transferred = contribution.getTransferred(); - if (transferred == 0 || transferred >= total) { - binding.contributionProgress.setIndeterminate(true); - } else { - binding.contributionProgress.setIndeterminate(false); - binding.contributionProgress.setProgress((int) (((double) transferred / (double) total) * 100)); - } - break; - case Contribution.STATE_PAUSED: - binding.contributionProgress.setVisibility(View.GONE); - binding.contributionState.setVisibility(View.VISIBLE); - binding.contributionState.setText(R.string.paused); - binding.cancelButton.setVisibility(View.VISIBLE); - binding.retryButton.setVisibility(View.GONE); - binding.pauseResumeButton.setVisibility(View.VISIBLE); - binding.imageOptions.setVisibility(View.VISIBLE); - setResume(); - if(pausingPopUp.isShowing()){ - pausingPopUp.hide(); - } - break; - case Contribution.STATE_FAILED: - binding.contributionState.setVisibility(View.VISIBLE); - binding.contributionState.setText(R.string.contribution_state_failed); - binding.contributionProgress.setVisibility(View.GONE); - binding.cancelButton.setVisibility(View.VISIBLE); - binding.retryButton.setVisibility(View.VISIBLE); - binding.pauseResumeButton.setVisibility(View.GONE); - binding.imageOptions.setVisibility(View.VISIBLE); - break; - } + binding.contributionState.setVisibility(View.GONE); + binding.contributionProgress.setVisibility(View.GONE); + binding.imageOptions.setVisibility(View.GONE); + binding.contributionState.setText(""); + checkIfMediaExistsOnWikipediaPage(contribution); + } /** @@ -196,8 +138,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { if (!mediaExists) { binding.wikipediaButton.setVisibility(View.VISIBLE); isWikipediaButtonDisplayed = true; - binding.cancelButton.setVisibility(View.GONE); - binding.retryButton.setVisibility(View.GONE); binding.imageOptions.setVisibility(View.VISIBLE); } } @@ -217,20 +157,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { null; } - /** - * Retry upload when it is failed - */ - public void retryUpload() { - callback.retryUpload(contribution); - } - - /** - * Delete a failed upload attempt - */ - public void deleteUpload() { - callback.deleteUpload(contribution); - } - public void imageClicked() { callback.openMediaDetail(position, isWikipediaButtonDisplayed); } @@ -239,44 +165,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { callback.addImageToWikipedia(contribution); } - /** - * Triggers a callback for pause/resume - */ - public void onPauseResumeButtonClicked() { - if (binding.pauseResumeButton.getTag().toString().equals("pause")) { - pause(); - } else { - resume(); - } - } - - private void resume() { - callback.resumeUpload(contribution); - setPaused(); - } - - private void pause() { - pausingPopUp.show(); - callback.pauseUpload(contribution); - setResume(); - } - - /** - * Update pause/resume button to show pause state - */ - private void setPaused() { - binding.pauseResumeButton.setImageResource(R.drawable.pause_icon); - binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.pause)); - } - - /** - * Update pause/resume button to show resume state - */ - private void setResume() { - binding.pauseResumeButton.setImageResource(R.drawable.play_icon); - binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.resume)); - } - public ImageRequest getImageRequest() { return imageRequest; } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java index b8a2488b2..439780332 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java @@ -19,8 +19,5 @@ public class ContributionsContract { Contribution getContributionsWithTitle(String uri); - void deleteUpload(Contribution contribution); - - void saveContribution(Contribution contribution); } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java index 189b2665f..ca9677691 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java @@ -12,6 +12,7 @@ import android.Manifest; import android.Manifest.permission; import android.annotation.SuppressLint; import android.content.Context; +import android.content.Intent; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; @@ -21,11 +22,10 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; -import android.widget.LinearLayout; +import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultCallback; @@ -36,7 +36,6 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; import androidx.fragment.app.FragmentTransaction; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.databinding.FragmentContributionsBinding; @@ -44,6 +43,8 @@ import fr.free.nrw.commons.notification.models.Notification; import fr.free.nrw.commons.notification.NotificationController; import fr.free.nrw.commons.profile.ProfileActivity; import fr.free.nrw.commons.theme.BaseActivity; +import fr.free.nrw.commons.upload.UploadProgressActivity; +import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.Map; @@ -104,6 +105,8 @@ public class ContributionsFragment LocationServiceManager locationManager; @Inject NotificationController notificationController; + @Inject + ContributionController contributionController; private CompositeDisposable compositeDisposable = new CompositeDisposable(); @@ -113,10 +116,10 @@ public class ContributionsFragment static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag"; private static final int MAX_RETRIES = 10; - public FragmentContributionsBinding binding; - @Inject ContributionsPresenter contributionsPresenter; + @Inject + ContributionsPresenter contributionsPresenter; @Inject SessionManager sessionManager; @@ -129,6 +132,12 @@ public class ContributionsFragment public TextView notificationCount; + public TextView pendingUploadsCountTextView; + + public TextView uploadsErrorTextView; + + public ImageView pendingUploadsImageView; + private Campaign wlmCampaign; String userName; @@ -147,20 +156,22 @@ public class ContributionsFragment areAllGranted = areAllGranted && b; } - if (areAllGranted) { - onLocationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) - && store.getBoolean("displayLocationPermissionForCardView", true) - && !store.getBoolean("doNotAskForLocationPermission", false) - && (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) { - binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION; + if (areAllGranted) { + onLocationPermissionGranted(); } else { - displayYouWontSeeNearbyMessage(); + if (shouldShowRequestPermissionRationale( + Manifest.permission.ACCESS_FINE_LOCATION) + && store.getBoolean("displayLocationPermissionForCardView", true) + && !store.getBoolean("doNotAskForLocationPermission", false) + && (((MainActivity) getActivity()).activeFragment + == ActiveFragment.CONTRIBUTIONS)) { + binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION; + } else { + displayYouWontSeeNearbyMessage(); + } } } - } - }); + }); @NonNull public static ContributionsFragment newInstance() { @@ -198,11 +209,10 @@ public class ContributionsFragment checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { // Do not ask for permission on activity start again - store.putBoolean("displayLocationPermissionForCardView",false); + store.putBoolean("displayLocationPermissionForCardView", false); } }); - if (savedInstanceState != null) { mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager() .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG); @@ -212,9 +222,7 @@ public class ContributionsFragment } initFragments(); - if(isUserProfile) { - binding.limitedConnectionEnabledLayout.setVisibility(View.GONE); - }else { + if (!isUserProfile) { upDateUploadCount(); } if (shouldShowMediaDetailsFragment) { @@ -230,7 +238,6 @@ public class ContributionsFragment && sessionManager.getCurrentAccount() != null && !isUserProfile) { setUploadCount(); } - binding.limitedConnectionEnabledLayout.setOnClickListener(toggleDescriptionListener); setHasOptionsMenu(true); return binding.getRoot(); } @@ -258,10 +265,32 @@ public class ContributionsFragment MenuItem notificationsMenuItem = menu.findItem(R.id.notifications); final View notification = notificationsMenuItem.getActionView(); notificationCount = notification.findViewById(R.id.notification_count_badge); + MenuItem uploadMenuItem = menu.findItem(R.id.upload_tab); + final View uploadMenuItemActionView = uploadMenuItem.getActionView(); + pendingUploadsCountTextView = uploadMenuItemActionView.findViewById( + R.id.pending_uploads_count_badge); + uploadsErrorTextView = uploadMenuItemActionView.findViewById( + R.id.uploads_error_count_badge); + pendingUploadsImageView = uploadMenuItemActionView.findViewById( + R.id.pending_uploads_image_view); + if (pendingUploadsImageView != null) { + pendingUploadsImageView.setOnClickListener(view -> { + startActivity(new Intent(getContext(), UploadProgressActivity.class)); + }); + } + if (pendingUploadsCountTextView != null) { + pendingUploadsCountTextView.setOnClickListener(view -> { + startActivity(new Intent(getContext(), UploadProgressActivity.class)); + }); + } + if (uploadsErrorTextView != null) { + uploadsErrorTextView.setOnClickListener(view -> { + startActivity(new Intent(getContext(), UploadProgressActivity.class)); + }); + } notification.setOnClickListener(view -> { - NotificationActivity.startYourself(getContext(), "unread"); + NotificationActivity.Companion.startYourself(getContext(), "unread"); }); - updateLimitedConnectionToggle(menu); } @SuppressLint("CheckResult") @@ -273,6 +302,34 @@ public class ContributionsFragment throwable -> Timber.e(throwable, "Error occurred while loading notifications"))); } + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Sets the visibility of the upload icon based on the number of failed and pending + * contributions. + */ +// public void setUploadIconVisibility() { +// contributionController.getFailedAndPendingContributions(); +// contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), +// list -> { +// updateUploadIcon(list.size()); +// }); +// } + + /** + * Sets the count for the upload icon based on the number of pending and failed contributions. + */ + public void setUploadIconCount() { + contributionController.getPendingContributions(); + contributionController.pendingContributionList.observe(getViewLifecycleOwner(), + list -> { + updatePendingIcon(list.size()); + }); + contributionController.getFailedContributions(); + contributionController.failedContributionList.observe(getViewLifecycleOwner(), list -> { + updateErrorIcon(list.size()); + }); + } + public void scrollToTop() { if (contributionsListFragment != null) { contributionsListFragment.scrollToTop(); @@ -289,29 +346,6 @@ public class ContributionsFragment } } - public void updateLimitedConnectionToggle(Menu menu) { - MenuItem checkable = menu.findItem(R.id.toggle_limited_connection_mode); - boolean isEnabled = store - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false); - - checkable.setChecked(isEnabled); - if (binding!=null) { - binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE); - } - - checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24); - checkable.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - ((MainActivity) getActivity()).toggleLimitedConnectionMode(); - boolean isEnabled = store.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false); - binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE); - checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24); - return false; - } - }); - } - @Override public void onAttach(Context context) { super.onAttach(context); @@ -355,7 +389,7 @@ public class ContributionsFragment } private void setupViewForMediaDetails() { - if (binding!=null) { + if (binding != null) { binding.campaignsView.setVisibility(View.GONE); } } @@ -465,7 +499,7 @@ public class ContributionsFragment contributionsPresenter.onAttachView(this); locationManager.addLocationListener(this); - if (binding==null) { + if (binding == null) { return; } @@ -484,7 +518,8 @@ public class ContributionsFragment } catch (Exception e) { Timber.e(e); } - if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) { + if (binding.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY) { binding.cardViewNearby.setVisibility(View.VISIBLE); } @@ -494,16 +529,20 @@ public class ContributionsFragment } // Notification Count and Campaigns should not be set, if it is used in User Profile - if(!isUserProfile) { + if (!isUserProfile) { setNotificationCount(); fetchCampaigns(); + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // setUploadIconVisibility(); + setUploadIconCount(); } } mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI); } private void checkPermissionsAndShowNearbyCardView() { - if (PermissionUtils.hasPermission(getActivity(), new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) { + if (PermissionUtils.hasPermission(getActivity(), + new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) { onLocationPermissionGranted(); } else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) && store.getBoolean("displayLocationPermissionForCardView", true) @@ -529,8 +568,8 @@ public class ContributionsFragment getString(R.string.nearby_card_permission_explanation), this::requestLocationPermission, this::displayYouWontSeeNearbyMessage, - checkBoxView, - false); + checkBoxView + ); } private void displayYouWontSeeNearbyMessage() { @@ -636,14 +675,14 @@ public class ContributionsFragment */ private void fetchCampaigns() { if (Utils.isMonumentsEnabled(new Date())) { - if (binding!=null) { + if (binding != null) { binding.campaignsView.setCampaign(wlmCampaign); binding.campaignsView.setVisibility(View.VISIBLE); } } else if (store.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) { presenter.getCampaigns(); } else { - if (binding!=null) { + if (binding != null) { binding.campaignsView.setVisibility(View.GONE); } } @@ -657,7 +696,7 @@ public class ContributionsFragment @Override public void showCampaigns(Campaign campaign) { if (campaign != null && !isUserProfile) { - if (binding!=null) { + if (binding != null) { binding.campaignsView.setCampaign(campaign); } } @@ -676,67 +715,6 @@ public class ContributionsFragment } } - /** - * Restarts the upload process for a contribution - * - * @param contribution - */ - public void restartUpload(Contribution contribution) { - contribution.setState(Contribution.STATE_QUEUED); - contributionsPresenter.saveContribution(contribution); - Timber.d("Restarting for %s", contribution.toString()); - } - - /** - * Retry upload when it is failed - * - * @param contribution contribution to be retried - */ - @Override - public void retryUpload(Contribution contribution) { - if (NetworkUtils.isInternetConnectionEstablished(getContext())) { - if (contribution.getState() == STATE_PAUSED - || contribution.getState() == Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) { - restartUpload(contribution); - } else if (contribution.getState() == STATE_FAILED) { - int retries = contribution.getRetries(); - // TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562 - /* Limit the number of retries for a failed upload - to handle cases like invalid filename as such uploads - will never be successful */ - if (retries < MAX_RETRIES) { - contribution.setRetries(retries + 1); - Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(), - retries + 1); - restartUpload(contribution); - } else { - // TODO: Show the exact reason for failure - Toast.makeText(getContext(), - R.string.retry_limit_reached, Toast.LENGTH_SHORT).show(); - } - } else { - Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); - } - } else { - ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection); - } - - } - - /** - * Pauses the upload - * - * @param contribution - */ - @Override - public void pauseUpload(Contribution contribution) { - //Pause the upload in the global singleton - CommonsApplication.pauseUploads.put(contribution.getPageId(), true); - //Retain the paused state in DB - contribution.setState(STATE_PAUSED); - contributionsPresenter.saveContribution(contribution); - } - /** * Notify the viewpager that number of items have changed. */ @@ -747,6 +725,53 @@ public class ContributionsFragment } } + /** + * Updates the visibility and text of the pending uploads count TextView based on the given + * count. + * + * @param pendingCount The number of pending uploads. + */ + public void updatePendingIcon(int pendingCount) { + if (pendingUploadsCountTextView != null) { + if (pendingCount != 0) { + pendingUploadsCountTextView.setVisibility(View.VISIBLE); + pendingUploadsCountTextView.setText(String.valueOf(pendingCount)); + } else { + pendingUploadsCountTextView.setVisibility(View.INVISIBLE); + } + } + } + + /** + * Updates the visibility and text of the error uploads TextView based on the given count. + * + * @param errorCount The number of error uploads. + */ + public void updateErrorIcon(int errorCount) { + if (uploadsErrorTextView != null) { + if (errorCount != 0) { + uploadsErrorTextView.setVisibility(View.VISIBLE); + uploadsErrorTextView.setText(String.valueOf(errorCount)); + } else { + uploadsErrorTextView.setVisibility(View.GONE); + } + } + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * @param count The number of pending uploads. + */ +// public void updateUploadIcon(int count) { +// if (pendingUploadsImageView != null) { +// if (count != 0) { +// pendingUploadsImageView.setVisibility(View.VISIBLE); +// } else { +// pendingUploadsImageView.setVisibility(View.GONE); +// } +// } +// } + /** * Replace whatever is in the current contributionsFragmentContainer view with * mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects @@ -782,7 +807,8 @@ public class ContributionsFragment public boolean backButtonClicked() { if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) { if (store.getBoolean("displayNearbyCardView", true) && !isUserProfile) { - if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) { + if (binding.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY) { binding.cardViewNearby.setVisibility(View.VISIBLE); } } else { @@ -829,6 +855,60 @@ public class ContributionsFragment } + /** + * Restarts the upload process for a contribution + * + * @param contribution + */ + public void restartUpload(Contribution contribution) { + contribution.setDateUploadStarted(Calendar.getInstance().getTime()); + if (contribution.getState() == Contribution.STATE_FAILED) { + if (contribution.getErrorInfo() == null) { + contribution.setChunkInfo(null); + contribution.setTransferred(0); + } + contributionsPresenter.checkDuplicateImageAndRestartContribution(contribution); + } else { + contribution.setState(Contribution.STATE_QUEUED); + contributionsPresenter.saveContribution(contribution); + Timber.d("Restarting for %s", contribution.toString()); + } + } + + /** + * Retry upload when it is failed + * + * @param contribution contribution to be retried + */ + public void retryUpload(Contribution contribution) { + if (NetworkUtils.isInternetConnectionEstablished(getContext())) { + if (contribution.getState() == STATE_PAUSED) { + restartUpload(contribution); + } else if (contribution.getState() == STATE_FAILED) { + int retries = contribution.getRetries(); + // TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562 + /* Limit the number of retries for a failed upload + to handle cases like invalid filename as such uploads + will never be successful */ + if (retries < MAX_RETRIES) { + contribution.setRetries(retries + 1); + Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(), + retries + 1); + restartUpload(contribution); + } else { + // TODO: Show the exact reason for failure + Toast.makeText(getContext(), + R.string.retry_limit_reached, Toast.LENGTH_SHORT).show(); + } + } else { + Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); + } + } else { + ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection); + } + + } + /** * Reload media detail fragment once media is nominated * @@ -844,21 +924,6 @@ public class ContributionsFragment } } - // click listener to toggle description that means uses can press the limited connection - // banner and description will hide. Tap again to show description. - private View.OnClickListener toggleDescriptionListener = new View.OnClickListener() { - - @Override - public void onClick(View view) { - View view2 = binding.limitedConnectionDescriptionTextView; - if (view2.getVisibility() == View.GONE) { - view2.setVisibility(View.VISIBLE); - } else { - view2.setVisibility(View.GONE); - } - } - }; - /** * When the device rotates, rotate the Nearby banner's compass arrow in tandem. */ diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java index e6db7e3de..3f9e8d541 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java @@ -70,16 +70,8 @@ public class ContributionsListAdapter extends public interface Callback { - void retryUpload(Contribution contribution); - - void deleteUpload(Contribution contribution); - void openMediaDetail(int contribution, boolean isWikipediaPageExists); void addImageToWikipedia(Contribution contribution); - - void pauseUpload(Contribution contribution); - - void resumeUpload(Contribution contribution); } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java index e3ec66b73..0d0a19436 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.contributions; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import fr.free.nrw.commons.BasePresenter; /** @@ -18,6 +19,7 @@ public class ContributionsListContract { public interface UserActionListener extends BasePresenter { - void deleteUpload(Contribution contribution); + void refreshList(SwipeRefreshLayout swipeRefreshLayout); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 2dbe8aff0..df65a91cc 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -6,6 +6,7 @@ import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_ import android.Manifest.permission; import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; @@ -19,7 +20,8 @@ import android.view.animation.AnimationUtils; import android.widget.LinearLayout; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -30,11 +32,11 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import androidx.recyclerview.widget.RecyclerView.ItemAnimator; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; import androidx.recyclerview.widget.SimpleItemAnimator; -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.contributions.ContributionsListAdapter.Callback; import fr.free.nrw.commons.databinding.FragmentContributionsListBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.media.MediaClient; @@ -42,7 +44,6 @@ import fr.free.nrw.commons.profile.ProfileActivity; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.SystemThemeUtils; import fr.free.nrw.commons.utils.ViewUtil; -import java.util.Locale; import java.util.Map; import java.util.Objects; import javax.inject.Inject; @@ -56,7 +57,7 @@ import fr.free.nrw.commons.wikidata.model.WikiSite; */ public class ContributionsListFragment extends CommonsDaggerSupportFragment implements - ContributionsListContract.View, ContributionsListAdapter.Callback, + ContributionsListContract.View, Callback, WikipediaInstructionsDialogFragment.Callback { private static final String RV_STATE = "rv_scroll_state"; @@ -81,7 +82,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl private Animation rotate_forward; private Animation rotate_backward; private boolean isFabOpen; - @VisibleForTesting protected RecyclerView rvContributionsList; @@ -98,8 +98,32 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl private int contributionsSize; private String userName; + private final ActivityResultLauncher galleryPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher customSelectorLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher cameraPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); + }); + private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( - new ActivityResultContracts.RequestMultiplePermissions(), + new RequestMultiplePermissions(), new ActivityResultCallback>() { @Override public void onActivityResult(Map result) { @@ -113,7 +137,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl } else { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { controller.handleShowRationaleFlowCameraLocation(getActivity(), - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } else { controller.locationPermissionCallback.onLocationPermissionDenied( getActivity().getString( @@ -151,7 +175,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl contributionsListPresenter.onAttachView(this); binding.fabCustomGallery.setOnClickListener(v -> launchCustomSelector()); binding.fabCustomGallery.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(),R.string.custom_selector_title); + ViewUtil.showShortToast(getContext(), R.string.custom_selector_title); return true; }); @@ -160,12 +184,22 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl binding.fabLayout.setVisibility(VISIBLE); } else { binding.tvContributionsOfUser.setVisibility(VISIBLE); - binding.tvContributionsOfUser.setText(getString(R.string.contributions_of_user, userName)); + binding.tvContributionsOfUser.setText( + getString(R.string.contributions_of_user, userName)); binding.fabLayout.setVisibility(GONE); } initAdapter(); + // pull down to refresh only enabled for self user. + if(Objects.equals(sessionManager.getUserName(), userName)){ + binding.swipeRefreshLayout.setOnRefreshListener(() -> { + contributionsListPresenter.refreshList(binding.swipeRefreshLayout); + }); + } else { + binding.swipeRefreshLayout.setEnabled(false); + } + return binding.getRoot(); } @@ -305,8 +339,9 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl public void onConfigurationChanged(final Configuration newConfig) { super.onConfigurationChanged(newConfig); // check orientation - binding.fabLayout.setOrientation(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? - LinearLayout.HORIZONTAL : LinearLayout.VERTICAL); + binding.fabLayout.setOrientation( + newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? + LinearLayout.HORIZONTAL : LinearLayout.VERTICAL); rvContributionsList .setLayoutManager( new GridLayoutManager(getContext(), getSpanCount(newConfig.orientation))); @@ -322,19 +357,19 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl private void setListeners() { binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); binding.fabCamera.setOnClickListener(view -> { - controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher); + controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); animateFAB(isFabOpen); }); binding.fabCamera.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(),R.string.add_contribution_from_camera); + ViewUtil.showShortToast(getContext(), R.string.add_contribution_from_camera); return true; }); binding.fabGallery.setOnClickListener(view -> { - controller.initiateGalleryPick(getActivity(), true); + controller.initiateGalleryPick(getActivity(), galleryPickLauncherForResult, true); animateFAB(isFabOpen); }); binding.fabGallery.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(),R.string.menu_from_gallery); + ViewUtil.showShortToast(getContext(), R.string.menu_from_gallery); return true; }); } @@ -343,7 +378,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl * Launch Custom Selector. */ protected void launchCustomSelector() { - controller.initiateCustomGalleryPickWithPermission(getActivity()); + controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult); animateFAB(isFabOpen); } @@ -415,30 +450,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl } } - @Override - public void retryUpload(final Contribution contribution) { - if (null != callback) {//Just being safe, ideally they won't be called when detached - callback.retryUpload(contribution); - } - } - - @Override - public void deleteUpload(final Contribution contribution) { - DialogUtil.showAlertDialog(getActivity(), - String.format(Locale.getDefault(), - getString(R.string.cancelling_upload)), - String.format(Locale.getDefault(), - getString(R.string.cancel_upload_dialog)), - String.format(Locale.getDefault(), getString(R.string.yes)), String.format(Locale.getDefault(), getString(R.string.no)), - () -> { - ViewUtil.showShortToast(getContext(), R.string.cancelling_upload); - contributionsListPresenter.deleteUpload(contribution); - CommonsApplication.cancelledUploads.add(contribution.getPageId()); - }, () -> { - // Do nothing - }); - } - @Override public void openMediaDetail(final int position, boolean isWikipediaButtonDisplayed) { if (null != callback) {//Just being safe, ideally they won't be called when detached @@ -463,28 +474,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl }); } - /** - * Pauses the current upload - * - * @param contribution - */ - @Override - public void pauseUpload(Contribution contribution) { - ViewUtil.showShortToast(getContext(), R.string.pausing_upload); - callback.pauseUpload(contribution); - } - - /** - * Resumes the current upload - * - * @param contribution - */ - @Override - public void resumeUpload(Contribution contribution) { - ViewUtil.showShortToast(getContext(), R.string.resuming_upload); - callback.retryUpload(contribution); - } - /** * Display confirmation dialog with instructions when the user tries to add image to wikipedia * @@ -536,13 +525,10 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl void notifyDataSetChanged(); - void retryUpload(Contribution contribution); - void showDetail(int position, boolean isWikipediaButtonDisplayed); - void pauseUpload(Contribution contribution); - // Notify the viewpager that number of items have changed. void viewPagerNotifyDataSetChanged(); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java index 320ba88a2..100c8be03 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java @@ -1,17 +1,22 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; + import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.paging.DataSource; import androidx.paging.DataSource.Factory; import androidx.paging.LivePagedListBuilder; import androidx.paging.PagedList; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener; -import fr.free.nrw.commons.di.CommonsApplicationModule; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; +import java.util.Collections; import javax.inject.Inject; import javax.inject.Named; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; /** * The presenter class for Contributions @@ -32,11 +37,11 @@ public class ContributionsListPresenter implements UserActionListener { final ContributionBoundaryCallback contributionBoundaryCallback, final ContributionsRemoteDataSource contributionsRemoteDataSource, final ContributionsRepository repository, - @Named(CommonsApplicationModule.IO_THREAD) final Scheduler ioThreadScheduler) { + @Named(IO_THREAD) final Scheduler ioThreadScheduler) { this.contributionBoundaryCallback = contributionBoundaryCallback; this.repository = repository; this.ioThreadScheduler = ioThreadScheduler; - this.contributionsRemoteDataSource=contributionsRemoteDataSource; + this.contributionsRemoteDataSource = contributionsRemoteDataSource; compositeDisposable = new CompositeDisposable(); } @@ -71,10 +76,12 @@ public class ContributionsListPresenter implements UserActionListener { } else { contributionBoundaryCallback.setUserName(userName); shouldSetBoundaryCallback = true; - factory = repository.fetchContributions(); + factory = repository.fetchContributionsWithStates( + Collections.singletonList(Contribution.STATE_COMPLETED)); } - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, pagedListConfig); + LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, + pagedListConfig); if (shouldSetBoundaryCallback) { livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback); } @@ -90,14 +97,16 @@ public class ContributionsListPresenter implements UserActionListener { } /** - * Delete a failed contribution from the local db + * It is used to refresh list. + * + * @param swipeRefreshLayout used to stop refresh animation when + * refresh finishes. */ @Override - public void deleteUpload(final Contribution contribution) { - compositeDisposable.add(repository - .deleteContributionFromDB(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); + public void refreshList(final SwipeRefreshLayout swipeRefreshLayout) { + contributionBoundaryCallback.refreshList(() -> { + swipeRefreshLayout.setRefreshing(false); + return Unit.INSTANCE; + }); } - } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java index dcfca2519..77dcd5df9 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java @@ -1,16 +1,14 @@ package fr.free.nrw.commons.contributions; import androidx.paging.DataSource.Factory; +import fr.free.nrw.commons.kvstore.JsonKvStore; import io.reactivex.Completable; +import io.reactivex.Single; import java.util.ArrayList; import java.util.List; - import javax.inject.Inject; import javax.inject.Named; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import io.reactivex.Single; - /** * The LocalDataSource class for Contributions */ @@ -21,8 +19,8 @@ class ContributionsLocalDataSource { @Inject public ContributionsLocalDataSource( - @Named("default_preferences") final JsonKvStore defaultKVStore, - final ContributionDao contributionDao) { + @Named("default_preferences") final JsonKvStore defaultKVStore, + final ContributionDao contributionDao) { this.defaultKVStore = defaultKVStore; this.contributionDao = contributionDao; } @@ -38,17 +36,19 @@ class ContributionsLocalDataSource { * Fetch default number of contributions to be show, based on user preferences */ public long getLong(final String key) { - return defaultKVStore.getLong(key); + return defaultKVStore.getLong(key); } /** * Get contribution object from cursor + * * @param uri * @return */ public Contribution getContributionWithFileName(final String uri) { - final List contributionWithUri = contributionDao.getContributionWithTitle(uri); - if(!contributionWithUri.isEmpty()){ + final List contributionWithUri = contributionDao.getContributionWithTitle( + uri); + if (!contributionWithUri.isEmpty()) { return contributionWithUri.get(0); } return null; @@ -56,6 +56,7 @@ class ContributionsLocalDataSource { /** * Remove a contribution from the contributions table + * * @param contribution * @return */ @@ -63,15 +64,48 @@ class ContributionsLocalDataSource { return contributionDao.delete(contribution); } + /** + * Deletes contributions with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + public Completable deleteContributionsWithStates(List states) { + return contributionDao.deleteContributionsWithStates(states); + } + public Factory getContributions() { return contributionDao.fetchContributions(); } + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + public Factory getContributionsWithStates(List states) { + return contributionDao.getContributions(states); + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + public Factory getContributionsWithStatesSortedByDateUploadStarted( + List states) { + return contributionDao.getContributionsSortedByDateUploadStarted(states); + } + public Single> saveContributions(final List contributions) { final List contributionList = new ArrayList<>(); - for(final Contribution contribution: contributions) { - final Contribution oldContribution = contributionDao.getContribution(contribution.getPageId()); - if(oldContribution != null) { + for (final Contribution contribution : contributions) { + final Contribution oldContribution = contributionDao.getContribution( + contribution.getPageId()); + if (oldContribution != null) { contribution.setWikidataPlace(oldContribution.getWikidataPlace()); } contributionList.add(contribution); @@ -84,10 +118,14 @@ class ContributionsLocalDataSource { } public void set(final String key, final long value) { - defaultKVStore.putLong(key,value); + defaultKVStore.putLong(key, value); } public Completable updateContribution(final Contribution contribution) { return contributionDao.update(contribution); } + + public Completable updateContributionsWithStates(List states, int newState) { + return contributionDao.updateContributionsWithStates(states, newState); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java index f676f193a..495a4bc64 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java @@ -1,21 +1,27 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; + import androidx.work.ExistingWorkPolicy; import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener; import fr.free.nrw.commons.di.CommonsApplicationModule; +import fr.free.nrw.commons.repository.UploadRepository; import fr.free.nrw.commons.upload.worker.WorkRequestHelper; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; import javax.inject.Inject; import javax.inject.Named; +import timber.log.Timber; /** * The presenter class for Contributions */ public class ContributionsPresenter implements UserActionListener { - private final ContributionsRepository repository; + private final ContributionsRepository contributionsRepository; + private final UploadRepository uploadRepository; private final Scheduler ioThreadScheduler; private CompositeDisposable compositeDisposable; private ContributionsContract.View view; @@ -25,15 +31,17 @@ public class ContributionsPresenter implements UserActionListener { @Inject ContributionsPresenter(ContributionsRepository repository, - @Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { - this.repository = repository; - this.ioThreadScheduler=ioThreadScheduler; + UploadRepository uploadRepository, + @Named(IO_THREAD) Scheduler ioThreadScheduler) { + this.contributionsRepository = repository; + this.uploadRepository = uploadRepository; + this.ioThreadScheduler = ioThreadScheduler; } @Override public void onAttachView(ContributionsContract.View view) { this.view = view; - compositeDisposable=new CompositeDisposable(); + compositeDisposable = new CompositeDisposable(); } @Override @@ -44,19 +52,30 @@ public class ContributionsPresenter implements UserActionListener { @Override public Contribution getContributionsWithTitle(String title) { - return repository.getContributionWithFileName(title); + return contributionsRepository.getContributionWithFileName(title); } /** - * Delete a failed contribution from the local db - * @param contribution + * Checks if a contribution is a duplicate and restarts the contribution process if it is not. + * + * @param contribution The contribution to check and potentially restart. */ - @Override - public void deleteUpload(Contribution contribution) { - compositeDisposable.add(repository - .deleteContributionFromDB(contribution) + public void checkDuplicateImageAndRestartContribution(Contribution contribution) { + compositeDisposable.add(uploadRepository + .checkDuplicateImage(contribution.getLocalUriPath().getPath()) .subscribeOn(ioThreadScheduler) - .subscribe()); + .subscribe(imageCheckResult -> { + if (imageCheckResult == IMAGE_OK) { + contribution.setState(Contribution.STATE_QUEUED); + saveContribution(contribution); + } else { + Timber.e("Contribution already exists"); + compositeDisposable.add(contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe()); + } + })); } /** @@ -65,9 +84,8 @@ public class ContributionsPresenter implements UserActionListener { * * @param contribution */ - @Override public void saveContribution(Contribution contribution) { - compositeDisposable.add(repository + compositeDisposable.add(contributionsRepository .save(contribution) .subscribeOn(ioThreadScheduler) .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt index 777bb8a21..e8ff01b3e 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons.contributions import androidx.paging.ItemKeyedDataSource -import fr.free.nrw.commons.di.CommonsApplicationModule +import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD import fr.free.nrw.commons.media.MediaClient import io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable @@ -12,62 +12,61 @@ import javax.inject.Named /** * Data-Source which acts as mediator for contributions-data from the API */ -class ContributionsRemoteDataSource @Inject constructor( - private val mediaClient: MediaClient, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler -) : ItemKeyedDataSource() { - private val compositeDisposable: CompositeDisposable = CompositeDisposable() - var userName: String? = null +class ContributionsRemoteDataSource + @Inject + constructor( + private val mediaClient: MediaClient, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, + ) : ItemKeyedDataSource() { + private val compositeDisposable: CompositeDisposable = CompositeDisposable() + var userName: String? = null - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - fetchContributions(callback) + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback, + ) { + fetchContributions(callback) + } + + override fun loadAfter( + params: LoadParams, + callback: LoadCallback, + ) { + fetchContributions(callback) + } + + override fun loadBefore( + params: LoadParams, + callback: LoadCallback, + ) { + } + + override fun getKey(item: Contribution): Int = item.pageId.hashCode() + + /** + * Fetches contributions using the MediaWiki API + */ + private fun fetchContributions(callback: LoadCallback) { + compositeDisposable.add( + mediaClient + .getMediaListForUser(userName!!) + .map { mediaList -> + mediaList.map { + Contribution(media = it, state = Contribution.STATE_COMPLETED) + } + }.subscribeOn(ioThreadScheduler) + .subscribe({ + callback.onResult(it) + }) { error: Throwable -> + Timber.e( + "Failed to fetch contributions: %s", + error.message, + ) + }, + ) + } + + fun dispose() { + compositeDisposable.dispose() + } } - - override fun loadAfter( - params: LoadParams, - callback: LoadCallback - ) { - fetchContributions(callback) - } - - override fun loadBefore( - params: LoadParams, - callback: LoadCallback - ) { - } - - override fun getKey(item: Contribution): Int { - return item.pageId.hashCode() - } - - - /** - * Fetches contributions using the MediaWiki API - */ - private fun fetchContributions(callback: LoadCallback) { - compositeDisposable.add( - mediaClient.getMediaListForUser(userName!!) - .map { mediaList -> - mediaList.map { - Contribution(media = it, state = Contribution.STATE_COMPLETED) - } - } - .subscribeOn(ioThreadScheduler) - .subscribe({ - callback.onResult(it) - }) { error: Throwable -> - Timber.e( - "Failed to fetch contributions: %s", - error.message - ) - } - ) - } - - fun dispose() { - compositeDisposable.dispose() - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java index 8054cfb4a..3808eba8e 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java @@ -29,6 +29,7 @@ public class ContributionsRepository { /** * Deletes a failed upload from DB + * * @param contribution * @return */ @@ -36,8 +37,19 @@ public class ContributionsRepository { return localDataSource.deleteContribution(contribution); } + /** + * Deletes contributions from the database with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + public Completable deleteContributionsFromDBWithStates(List states) { + return localDataSource.deleteContributionsWithStates(states); + } + /** * Get contribution object with title + * * @param fileName * @return */ @@ -49,19 +61,52 @@ public class ContributionsRepository { return localDataSource.getContributions(); } + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + public Factory fetchContributionsWithStates(List states) { + return localDataSource.getContributionsWithStates(states); + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + public Factory fetchContributionsWithStatesSortedByDateUploadStarted( + List states) { + return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states); + } + public Single> save(List contributions) { return localDataSource.saveContributions(contributions); } - public Completable save(Contribution contributions){ + public Completable save(Contribution contributions) { return localDataSource.saveContributions(contributions); } public void set(String key, long value) { - localDataSource.set(key,value); + localDataSource.set(key, value); } public Completable updateContribution(Contribution contribution) { return localDataSource.updateContribution(contribution); } + + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + public Completable updateContributionsWithStates(List states, int newState) { + return localDataSource.updateContributionsWithStates(states, newState); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java index 7862493fd..03027f287 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java @@ -1,13 +1,10 @@ package fr.free.nrw.commons.contributions; -import android.Manifest.permission; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -16,10 +13,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import androidx.viewpager.widget.ViewPager; import androidx.work.ExistingWorkPolicy; import fr.free.nrw.commons.databinding.MainBinding; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.R; import fr.free.nrw.commons.WelcomeActivity; import fr.free.nrw.commons.auth.SessionManager; @@ -41,18 +36,20 @@ import fr.free.nrw.commons.notification.NotificationController; import fr.free.nrw.commons.quiz.QuizChecker; import fr.free.nrw.commons.settings.SettingsFragment; import fr.free.nrw.commons.theme.BaseActivity; +import fr.free.nrw.commons.upload.UploadProgressActivity; import fr.free.nrw.commons.upload.worker.WorkRequestHelper; import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.ViewUtilWrapper; import io.reactivex.Completable; import io.reactivex.schedulers.Schedulers; +import java.util.Calendar; import java.util.Collections; import java.util.List; import javax.inject.Inject; import javax.inject.Named; import timber.log.Timber; -public class MainActivity extends BaseActivity +public class MainActivity extends BaseActivity implements FragmentManager.OnBackStackChangedListener { @Inject @@ -142,17 +139,18 @@ public class MainActivity extends BaseActivity } else { if (applicationKvStore.getBoolean("firstrun", true)) { applicationKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", false); + applicationKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", false); } - if(savedInstanceState == null){ + if (savedInstanceState == null) { //starting a fresh fragment. // Open Last opened screen if it is Contributions or Nearby, otherwise Contributions - if(applicationKvStore.getBoolean("last_opened_nearby")){ + if (applicationKvStore.getBoolean("last_opened_nearby")) { setTitle(getString(R.string.nearby_fragment)); showNearby(); - loadFragment(NearbyParentFragment.newInstance(),false); - }else{ + loadFragment(NearbyParentFragment.newInstance(), false); + } else { setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(),false); + loadFragment(ContributionsFragment.newInstance(), false); } } setUpPager(); @@ -161,14 +159,16 @@ public class MainActivity extends BaseActivity * so that location in the EXIF metadata of the images shared by the user * is retained on devices running Android 10 or above */ - if (VERSION.SDK_INT >= VERSION_CODES.Q) { - PermissionUtils.checkPermissionsAndPerformAction( - this, - () -> {}, - R.string.media_location_permission_denied, - R.string.add_location_manually, - permission.ACCESS_MEDIA_LOCATION); - } +// if (VERSION.SDK_INT >= VERSION_CODES.Q) { +// ActivityCompat.requestPermissions(this, +// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0); +// PermissionUtils.checkPermissionsAndPerformAction( +// this, +// () -> {}, +// R.string.media_location_permission_denied, +// R.string.add_location_manually, +// permission.ACCESS_MEDIA_LOCATION); +// } checkAndResumeStuckUploads(); } } @@ -178,32 +178,33 @@ public class MainActivity extends BaseActivity } private void setUpPager() { - binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(navListener = (item) -> { - if (!item.getTitle().equals(getString(R.string.more))) { - // do not change title for more fragment - setTitle(item.getTitle()); - } - // set last_opened_nearby true if item is nearby screen else set false - applicationKvStore.putBoolean("last_opened_nearby", - item.getTitle().equals(getString(R.string.nearby_fragment))); - final Fragment fragment = NavTab.of(item.getOrder()).newInstance(); - return loadFragment(fragment, true); - }); + binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( + navListener = (item) -> { + if (!item.getTitle().equals(getString(R.string.more))) { + // do not change title for more fragment + setTitle(item.getTitle()); + } + // set last_opened_nearby true if item is nearby screen else set false + applicationKvStore.putBoolean("last_opened_nearby", + item.getTitle().equals(getString(R.string.nearby_fragment))); + final Fragment fragment = NavTab.of(item.getOrder()).newInstance(); + return loadFragment(fragment, true); + }); } private void setUpLoggedOutPager() { - loadFragment(ExploreFragment.newInstance(),false); + loadFragment(ExploreFragment.newInstance(), false); binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(item -> { if (!item.getTitle().equals(getString(R.string.more))) { // do not change title for more fragment setTitle(item.getTitle()); } Fragment fragment = NavTabLoggedOut.of(item.getOrder()).newInstance(); - return loadFragment(fragment,true); + return loadFragment(fragment, true); }); } - private boolean loadFragment(Fragment fragment,boolean showBottom ) { + private boolean loadFragment(Fragment fragment, boolean showBottom) { //showBottom so that we do not show the bottom tray again when constructing //from the saved instance state. if (fragment instanceof ContributionsFragment) { @@ -233,7 +234,8 @@ public class MainActivity extends BaseActivity bookmarkFragment = (BookmarkFragment) fragment; activeFragment = ActiveFragment.BOOKMARK; } else if (fragment == null && showBottom) { - if (applicationKvStore.getBoolean("login_skipped") == true) { // If logged out, more sheet is different + if (applicationKvStore.getBoolean("login_skipped") + == true) { // If logged out, more sheet is different MoreBottomSheetLoggedOutFragment bottomSheet = new MoreBottomSheetLoggedOutFragment(); bottomSheet.show(getSupportFragmentManager(), "MoreBottomSheetLoggedOut"); @@ -263,28 +265,30 @@ public class MainActivity extends BaseActivity } /** - * Adds number of uploads next to tab text "Contributions" then it will look like - * "Contributions (NUMBER)" + * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions + * (NUMBER)" + * * @param uploadCount */ public void setNumOfUploads(int uploadCount) { if (activeFragment == ActiveFragment.CONTRIBUTIONS) { - setTitle(getResources().getString(R.string.contributions_fragment) +" "+ ( + setTitle(getResources().getString(R.string.contributions_fragment) + " " + ( !(uploadCount == 0) ? - getResources() - .getQuantityString(R.plurals.contributions_subtitle, - uploadCount, uploadCount):getString(R.string.contributions_subtitle_zero))); + getResources() + .getQuantityString(R.plurals.contributions_subtitle, + uploadCount, uploadCount) + : getString(R.string.contributions_subtitle_zero))); } } /** - * Resume the uploads that got stuck because of the app being killed - * or the device being rebooted. - * + * Resume the uploads that got stuck because of the app being killed or the device being + * rebooted. + *

* When the app is terminated or the device is restarted, contributions remain in the - * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. - * So, retrieving contributions labeled as 'STATE_IN_PROGRESS' - * from the database will provide the list of uploads that appear as stuck on opening the app again + * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, + * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the + * list of uploads that appear as stuck on opening the app again */ @SuppressLint("CheckResult") private void checkAndResumeStuckUploads() { @@ -293,9 +297,10 @@ public class MainActivity extends BaseActivity .subscribeOn(Schedulers.io()) .blockingGet(); Timber.d("Resuming " + stuckUploads.size() + " uploads..."); - if(!stuckUploads.isEmpty()) { - for(Contribution contribution: stuckUploads) { + if (!stuckUploads.isEmpty()) { + for (Contribution contribution : stuckUploads) { contribution.setState(Contribution.STATE_QUEUED); + contribution.setDateUploadStarted(Calendar.getInstance().getTime()); Completable.fromAction(() -> contributionDao.saveSynchronous(contribution)) .subscribeOn(Schedulers.io()) .subscribe(); @@ -322,24 +327,24 @@ public class MainActivity extends BaseActivity protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); String activeFragmentName = savedInstanceState.getString("activeFragment"); - if(activeFragmentName != null) { + if (activeFragmentName != null) { restoreActiveFragment(activeFragmentName); } } private void restoreActiveFragment(@NonNull String fragmentName) { - if(fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) { + if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) { setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(),false); - }else if(fragmentName.equals(ActiveFragment.NEARBY.name())) { + loadFragment(ContributionsFragment.newInstance(), false); + } else if (fragmentName.equals(ActiveFragment.NEARBY.name())) { setTitle(getString(R.string.nearby_fragment)); - loadFragment(NearbyParentFragment.newInstance(),false); - }else if(fragmentName.equals(ActiveFragment.EXPLORE.name())) { + loadFragment(NearbyParentFragment.newInstance(), false); + } else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) { setTitle(getString(R.string.navigation_item_explore)); - loadFragment(ExploreFragment.newInstance(),false); - }else if(fragmentName.equals(ActiveFragment.BOOKMARK.name())) { + loadFragment(ExploreFragment.newInstance(), false); + } else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) { setTitle(getString(R.string.bookmarks)); - loadFragment(BookmarkFragment.newInstance(),false); + loadFragment(BookmarkFragment.newInstance(), false); } } @@ -355,8 +360,9 @@ public class MainActivity extends BaseActivity // Means that nearby fragment is visible /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is not expanded. So if the back button is pressed, then go back to the Contributions tab */ - if(!nearbyParentFragment.backButtonClicked()){ - getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment).commit(); + if (!nearbyParentFragment.backButtonClicked()) { + getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment) + .commit(); setSelectedItemId(NavTab.CONTRIBUTIONS.code()); } } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { @@ -381,18 +387,6 @@ public class MainActivity extends BaseActivity //initBackButton(); } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.notifications: - // Starts notification activity on click to notification icon - NotificationActivity.startYourself(this, "unread"); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - /** * Retry all failed uploads as soon as the user returns to the app */ @@ -402,43 +396,40 @@ public class MainActivity extends BaseActivity getContribution(Collections.singletonList(Contribution.STATE_FAILED)) .subscribeOn(Schedulers.io()) .subscribe(failedUploads -> { - for (Contribution contribution: failedUploads) { + for (Contribution contribution : failedUploads) { contributionsFragment.retryUpload(contribution); } }); } - public void toggleLimitedConnectionMode() { - defaultKvStore.putBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - !defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)); - if (defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)) { - viewUtilWrapper - .showShortToast(getBaseContext(), getString(R.string.limited_connection_enabled)); - } else { - WorkRequestHelper.Companion.makeOneTimeWorkRequest(getApplicationContext(), - ExistingWorkPolicy.APPEND_OR_REPLACE); - viewUtilWrapper - .showShortToast(getBaseContext(), getString(R.string.limited_connection_disabled)); + /** + * Handles item selection in the options menu. This method is called when a user interacts with + * the options menu in the Top Bar. + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.upload_tab: + startActivity(new Intent(this, UploadProgressActivity.class)); + return true; + case R.id.notifications: + // Starts notification activity on click to notification icon + NotificationActivity.Companion.startYourself(this, "unread"); + return true; + default: + return super.onOptionsItemSelected(item); } } public void centerMapToPlace(Place place) { setSelectedItemId(NavTab.NEARBY.code()); - nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback(new NearbyParentFragmentInstanceReadyCallback() { - @Override - public void onReady() { - nearbyParentFragment.centerMapToPlace(place); - } - }); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - Timber.d(data!=null?data.toString():"onActivityResult data is null"); - super.onActivityResult(requestCode, resultCode, data); - controller.handleActivityResult(this, requestCode, resultCode, data); + nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback( + new NearbyParentFragmentInstanceReadyCallback() { + @Override + public void onReady() { + nearbyParentFragment.centerMapToPlace(place); + } + }); } @Override @@ -481,14 +472,15 @@ public class MainActivity extends BaseActivity /** * Load default language in onCreate from SharedPreferences */ - private void loadLocale(){ - final SharedPreferences preferences = getSharedPreferences("Settings", Activity.MODE_PRIVATE); + private void loadLocale() { + final SharedPreferences preferences = getSharedPreferences("Settings", + Activity.MODE_PRIVATE); final String language = preferences.getString("language", ""); final SettingsFragment settingsFragment = new SettingsFragment(); settingsFragment.setLocale(this, language); } - public NavTabLayout.OnNavigationItemSelectedListener getNavListener(){ + public NavTabLayout.OnNavigationItemSelectedListener getNavListener() { return navListener; } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java index c9b55a83c..0f18c300b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java @@ -5,14 +5,11 @@ import android.app.NotificationManager; import android.app.WallpaperManager; import android.content.Context; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import androidx.work.Data; import androidx.work.Worker; import androidx.work.WorkerParameters; import com.facebook.common.executors.CallerThreadExecutor; @@ -25,7 +22,6 @@ import com.facebook.imagepipeline.image.CloseableImage; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import fr.free.nrw.commons.R; -import java.io.IOException; import timber.log.Timber; public class SetWallpaperWorker extends Worker { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index e0f7e7c42..86cda2cf3 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -13,26 +13,30 @@ import fr.free.nrw.commons.databinding.DialogAddToWikipediaInstructionsBinding * Dialog fragment for displaying instructions for editing wikipedia */ class WikipediaInstructionsDialogFragment : DialogFragment() { - var callback: Callback? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ) = DialogAddToWikipediaInstructionsBinding.inflate(inflater, container, false).apply { - val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) - tvWikicode.setText(contribution?.media?.wikiCode) - instructionsCancel.setOnClickListener { dismiss() } - instructionsConfirm.setOnClickListener { - callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) - } - }.root + savedInstanceState: Bundle?, + ) = DialogAddToWikipediaInstructionsBinding + .inflate(inflater, container, false) + .apply { + val contribution: Contribution? = requireArguments().getParcelable(ARG_CONTRIBUTION) + tvWikicode.setText(contribution?.media?.wikiCode) + instructionsCancel.setOnClickListener { dismiss() } + instructionsConfirm.setOnClickListener { + callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) + } + }.root - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { super.onViewCreated(view, savedInstanceState) dialog!!.window?.setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN, ) } @@ -40,15 +44,19 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { * Callback for handling confirm button clicked */ interface Callback { - fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) + fun onConfirmClicked( + contribution: Contribution?, + copyWikicode: Boolean, + ) } companion object { const val ARG_CONTRIBUTION = "contribution" @JvmStatic - fun newInstance(contribution: Contribution) = WikipediaInstructionsDialogFragment().apply { - arguments = bundleOf(ARG_CONTRIBUTION to contribution) - } + fun newInstance(contribution: Contribution) = + WikipediaInstructionsDialogFragment().apply { + arguments = bundleOf(ARG_CONTRIBUTION to contribution) + } } } diff --git a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java deleted file mode 100644 index 8b6209342..000000000 --- a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java +++ /dev/null @@ -1,187 +0,0 @@ -package fr.free.nrw.commons.coordinates; - -import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_COORDINATES; - -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 io.reactivex.schedulers.Schedulers; -import java.util.Objects; -import javax.inject.Inject; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -/** - * Helper class for edit and update given coordinates and showing notification about new coordinates - * upgradation - */ -public class CoordinateEditHelper { - - /** - * notificationHelper: helps creating notification - */ - private final NotificationHelper notificationHelper; - /** - * * pageEditClient: methods provided by this member posts the edited coordinates - * to the Media wiki api - */ - public final PageEditClient pageEditClient; - /** - * viewUtil: helps to show Toast - */ - private final ViewUtilWrapper viewUtil; - - @Inject - public CoordinateEditHelper(final NotificationHelper notificationHelper, - @Named("commons-page-edit") final PageEditClient pageEditClient, - final ViewUtilWrapper viewUtil) { - this.notificationHelper = notificationHelper; - this.pageEditClient = pageEditClient; - this.viewUtil = viewUtil; - } - - /** - * Public interface to edit coordinates - * @param context to be added - * @param media to be added - * @param Accuracy to be added - * @return Single - */ - public Single makeCoordinatesEdit(final Context context, final Media media, - final String Latitude, final String Longitude, final String Accuracy) { - viewUtil.showShortToast(context, - context.getString(R.string.coordinates_edit_helper_make_edit_toast)); - return addCoordinates(media, Latitude, Longitude, Accuracy) - .flatMapSingle(result -> Single.just(showCoordinatesEditNotification(context, media, - Latitude, Longitude, Accuracy, result))) - .firstOrError(); - } - - /** - * Replaces new coordinates - * @param media to be added - * @param Latitude to be added - * @param Longitude to be added - * @param Accuracy to be added - * @return Observable - */ - private Observable addCoordinates(final Media media, final String Latitude, - final String Longitude, final String Accuracy) { - Timber.d("thread is coordinates adding %s", Thread.currentThread().getName()); - final String summary = "Adding Coordinates"; - - final StringBuilder buffer = new StringBuilder(); - - final String wikiText = pageEditClient.getCurrentWikiText(media.getFilename()) - .subscribeOn(Schedulers.io()) - .blockingGet(); - - if (Latitude != null) { - buffer.append("\n{{Location|").append(Latitude).append("|").append(Longitude) - .append("|").append(Accuracy).append("}}"); - } - - final String editedLocation = buffer.toString(); - final String appendText = getFormattedWikiText(wikiText, editedLocation); - - return pageEditClient.edit(Objects.requireNonNull(media.getFilename()) - , appendText, summary); - } - - /** - * Helps to get formatted wikitext with upgraded location - * @param wikiText current wikitext - * @param editedLocation new location - * @return String - */ - private String getFormattedWikiText(final String wikiText, final String editedLocation){ - - if (wikiText.contains("filedesc") && wikiText.contains("Location")) { - - final String fromLocationToEnd = wikiText.substring(wikiText.indexOf("{{Location")); - final String firstHalf = wikiText.substring(0, wikiText.indexOf("{{Location")); - final String lastHalf = fromLocationToEnd.substring( - fromLocationToEnd.indexOf("}}") + 2); - - final int startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, - "==", 3); - final StringBuilder buffer = new StringBuilder(); - if (wikiText.charAt(wikiText.indexOf("{{Location")-1) == '\n') { - buffer.append(editedLocation.substring(1)); - } else { - buffer.append(editedLocation); - } - if (startOfSecondSection != -1 && wikiText.charAt(startOfSecondSection-1)!= '\n') { - buffer.append("\n"); - } - - return firstHalf + buffer + lastHalf; - - } - if (wikiText.contains("filedesc") && !wikiText.contains("Location")) { - - final int startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, - "==", 3); - - if (startOfSecondSection != -1) { - final String firstHalf = wikiText.substring(0, startOfSecondSection); - final String lastHalf = wikiText.substring(startOfSecondSection); - final String buffer = editedLocation.substring(1) - + "\n"; - return firstHalf + buffer + lastHalf; - } - - return wikiText + editedLocation; - } - return "== {{int:filedesc}} ==" + editedLocation + wikiText; - } - - /** - * Update coordinates and shows notification about coordinates update - * @param context to be added - * @param media to be added - * @param latitude to be added - * @param longitude to be added - * @param Accuracy to be added - * @param result to be added - * @return boolean - */ - private boolean showCoordinatesEditNotification(final Context context, final Media media, - final String latitude, final String longitude, final String Accuracy, - final boolean result) { - final String message; - String title = context.getString(R.string.coordinates_edit_helper_show_edit_title); - - if (result) { - media.setCoordinates( - new fr.free.nrw.commons.location.LatLng(Double.parseDouble(latitude), - Double.parseDouble(longitude), - Float.parseFloat(Accuracy))); - title += ": " + context - .getString(R.string.coordinates_edit_helper_show_edit_title_success); - final StringBuilder coordinatesInMessage = new StringBuilder(); - final String mediaCoordinate = String.valueOf(media.getCoordinates()); - coordinatesInMessage.append(mediaCoordinate); - message = context.getString(R.string.coordinates_edit_helper_show_edit_message, - coordinatesInMessage.toString()); - } else { - title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title); - message = context.getString(R.string.coordinates_edit_helper_edit_message_else) ; - } - - final String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename(); - final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)); - notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_COORDINATES, - browserIntent); - return result; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt new file mode 100644 index 000000000..1bad2e2a5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt @@ -0,0 +1,189 @@ +package fr.free.nrw.commons.coordinates + + +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.notification.NotificationHelper.Companion.NOTIFICATION_EDIT_COORDINATES +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.util.Objects +import javax.inject.Inject +import javax.inject.Named +import org.apache.commons.lang3.StringUtils +import timber.log.Timber + + +/** + * Helper class for edit and update given coordinates and showing notification about new coordinates + * upgradation + */ +class CoordinateEditHelper @Inject constructor( + private val notificationHelper: NotificationHelper, + @Named("commons-page-edit") private val pageEditClient: PageEditClient, + private val viewUtil: ViewUtilWrapper +) { + + /** + * Public interface to edit coordinates + * @param context to be added + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @return Single + */ + fun makeCoordinatesEdit( + context: Context, + media: Media, + latitude: String, + longitude: String, + accuracy: String + ): Single? { + viewUtil.showShortToast( + context, + context.getString(R.string.coordinates_edit_helper_make_edit_toast) + ) + return addCoordinates(media, latitude, longitude, accuracy) + ?.flatMapSingle { result -> + Single.just(showCoordinatesEditNotification(context, media, latitude, longitude, accuracy, result)) + } + ?.firstOrError() + } + + /** + * Replaces new coordinates + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @return Observable + */ + private fun addCoordinates( + media: Media, + latitude: String, + longitude: String, + accuracy: String + ): Observable? { + Timber.d("thread is coordinates adding %s", Thread.currentThread().getName()) + val summary = "Adding Coordinates" + + val buffer = StringBuilder() + + val wikiText = media.filename?.let { + pageEditClient.getCurrentWikiText(it) + .subscribeOn(Schedulers.io()) + .blockingGet() + } + + if (latitude != null) { + buffer.append("\n{{Location|").append(latitude).append("|").append(longitude) + .append("|").append(accuracy).append("}}") + } + + val editedLocation = buffer.toString() + val appendText = wikiText?.let { getFormattedWikiText(it, editedLocation) } + + return Objects.requireNonNull(media.filename) + ?.let { pageEditClient.edit(it, appendText!!, summary) } + } + + /** + * Helps to get formatted wikitext with upgraded location + * @param wikiText current wikitext + * @param editedLocation new location + * @return String + */ + private fun getFormattedWikiText(wikiText: String, editedLocation: String): String { + if (wikiText.contains("filedesc") && wikiText.contains("Location")) { + val fromLocationToEnd = wikiText.substring(wikiText.indexOf("{{Location")) + val firstHalf = wikiText.substring(0, wikiText.indexOf("{{Location")) + val lastHalf = fromLocationToEnd.substring(fromLocationToEnd.indexOf("}}") + 2) + + val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3) + val buffer = StringBuilder() + if (wikiText[wikiText.indexOf("{{Location") - 1] == '\n') { + buffer.append(editedLocation.substring(1)) + } else { + buffer.append(editedLocation) + } + if (startOfSecondSection != -1 && wikiText[startOfSecondSection - 1] != '\n') { + buffer.append("\n") + } + + return firstHalf + buffer + lastHalf + } + if (wikiText.contains("filedesc") && !wikiText.contains("Location")) { + val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3) + + if (startOfSecondSection != -1) { + val firstHalf = wikiText.substring(0, startOfSecondSection) + val lastHalf = wikiText.substring(startOfSecondSection) + val buffer = editedLocation.substring(1) + "\n" + return firstHalf + buffer + lastHalf + } + + return wikiText + editedLocation + } + return "== {{int:filedesc}} ==$editedLocation$wikiText" + } + + /** + * Update coordinates and shows notification about coordinates update + * @param context to be added + * @param media to be added + * @param latitude to be added + * @param longitude to be added + * @param accuracy to be added + * @param result to be added + * @return boolean + */ + private fun showCoordinatesEditNotification( + context: Context, + media: Media, + latitude: String, + longitude: String, + accuracy: String, + result: Boolean + ): Boolean { + val message: String + var title = context.getString(R.string.coordinates_edit_helper_show_edit_title) + + if (result) { + media.coordinates = fr.free.nrw.commons.location.LatLng( + latitude.toDouble(), + longitude.toDouble(), + accuracy.toFloat() + ) + title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title_success) + val coordinatesInMessage = StringBuilder() + val mediaCoordinate = media.coordinates.toString() + coordinatesInMessage.append(mediaCoordinate) + message = context.getString( + R.string.coordinates_edit_helper_show_edit_message, + coordinatesInMessage.toString() + ) + } else { + title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title) + message = context.getString(R.string.coordinates_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_COORDINATES, + browserIntent + ) + return result + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt index 31d48af97..b3ef36318 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatus.kt @@ -1,16 +1,16 @@ package fr.free.nrw.commons.customselector.database -import androidx.room.* +import androidx.room.Entity +import androidx.room.PrimaryKey /** * Entity class for Not For Upload status. */ @Entity(tableName = "images_not_for_upload_table") data class NotForUploadStatus( - /** * Original image sha1. */ @PrimaryKey - val imageSHA1 : String + val imageSHA1: String, ) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt similarity index 70% rename from app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt index d6c9235ce..b75a6e1d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/NotForUploadStatusDao.kt @@ -1,18 +1,20 @@ package fr.free.nrw.commons.customselector.database -import androidx.room.* - +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query /** * Dao class for Not For Upload */ @Dao abstract class NotForUploadStatusDao { - /** * Insert into Not For Upload status. */ - @Insert( onConflict = OnConflictStrategy.REPLACE ) + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(notForUploadStatus: NotForUploadStatus) /** @@ -25,33 +27,27 @@ abstract class NotForUploadStatusDao { * Query Not For Upload status with image sha1. */ @Query("SELECT * FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") - abstract suspend fun getFromImageSHA1(imageSHA1 : String) : NotForUploadStatus? + abstract suspend fun getFromImageSHA1(imageSHA1: String): NotForUploadStatus? /** * Asynchronous image sha1 query. */ - suspend fun getNotForUploadFromImageSHA1(imageSHA1: String):NotForUploadStatus? { - return getFromImageSHA1(imageSHA1) - } + suspend fun getNotForUploadFromImageSHA1(imageSHA1: String): NotForUploadStatus? = getFromImageSHA1(imageSHA1) /** * Deletion Not For Upload status with image sha1. */ @Query("DELETE FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") - abstract suspend fun deleteWithImageSHA1(imageSHA1 : String) + abstract suspend fun deleteWithImageSHA1(imageSHA1: String) /** * Asynchronous image sha1 deletion. */ - suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) { - return deleteWithImageSHA1(imageSHA1) - } + suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) = deleteWithImageSHA1(imageSHA1) /** * Check whether the imageSHA1 is present in database */ @Query("SELECT COUNT() FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") - abstract suspend fun find(imageSHA1 : String): Int + abstract suspend fun find(imageSHA1: String): Int } - - diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt index 93e4a8243..7f635ed95 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt @@ -3,37 +3,32 @@ package fr.free.nrw.commons.customselector.database import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -import java.util.* +import java.util.Date /** * Entity class for Uploaded Status. */ @Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)]) data class UploadedStatus( - /** * Original image sha1. */ @PrimaryKey - val imageSHA1 : String, - + val imageSHA1: String, /** * Modified image sha1 (after exif changes). */ - val modifiedImageSHA1 : String, - + val modifiedImageSHA1: String, /** * imageSHA1 query result from API. */ - var imageResult : Boolean, - + var imageResult: Boolean, /** * modifiedImageSHA1 query result from API. */ - var modifiedImageResult : Boolean, - + var modifiedImageResult: Boolean, /** * lastUpdated for data validation. */ - var lastUpdated : Date? = null + var lastUpdated: Date? = null, ) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt similarity index 62% rename from app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt index 70454cc9e..378af5b8d 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatusDao.kt @@ -1,18 +1,22 @@ package fr.free.nrw.commons.customselector.database -import androidx.room.* -import java.util.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import java.util.Calendar /** * UploadedStatusDao for Custom Selector. */ @Dao abstract class UploadedStatusDao { - /** * Insert into uploaded status. */ - @Insert( onConflict = OnConflictStrategy.REPLACE ) + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(uploadedStatus: UploadedStatus) /** @@ -31,13 +35,13 @@ abstract class UploadedStatusDao { * Query uploaded status with image sha1. */ @Query("SELECT * FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) ") - abstract suspend fun getFromImageSHA1(imageSHA1 : String) : UploadedStatus? + abstract suspend fun getFromImageSHA1(imageSHA1: String): UploadedStatus? /** * Query uploaded status with modified image sha1. */ @Query("SELECT * FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) ") - abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1 : String) : UploadedStatus? + abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1: String): UploadedStatus? /** * Asynchronous insert into uploaded status table. @@ -51,20 +55,24 @@ abstract class UploadedStatusDao { * Check whether the imageSHA1 is present in database */ @Query("SELECT COUNT() FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) AND imageResult = (:imageResult) ") - abstract suspend fun findByImageSHA1(imageSHA1 : String, imageResult: Boolean): Int + abstract suspend fun findByImageSHA1( + imageSHA1: String, + imageResult: Boolean, + ): Int /** * Check whether the modifiedImageSHA1 is present in database */ - @Query("SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ") - abstract suspend fun findByModifiedImageSHA1(modifiedImageSHA1 : String, - modifiedImageResult: Boolean): Int + @Query( + "SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ", + ) + abstract suspend fun findByModifiedImageSHA1( + modifiedImageSHA1: String, + modifiedImageResult: Boolean, + ): Int /** * Asynchronous image sha1 query. */ - suspend fun getUploadedFromImageSHA1(imageSHA1: String):UploadedStatus? { - return getFromImageSHA1(imageSHA1) - } - -} \ No newline at end of file + suspend fun getUploadedFromImageSHA1(imageSHA1: String): UploadedStatus? = getFromImageSHA1(imageSHA1) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt index f28a1c613..e03b4da3c 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/CustomSelectorConstants.kt @@ -4,12 +4,10 @@ package fr.free.nrw.commons.customselector.helper * Stores constants related to custom image selector */ object CustomSelectorConstants { - const val BUCKET_ID = "bucket_id" const val TOTAL_SELECTED_IMAGES = "total_selected_images" const val PRESENT_POSITION = "present_position" const val NEW_SELECTED_IMAGES = "new_selected_images" const val SHOULD_REFRESH = "should_refresh" const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch" - -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt new file mode 100644 index 000000000..9a949b1cf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -0,0 +1,249 @@ +package fr.free.nrw.commons.customselector.helper + +import android.content.ContentUris +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AlertDialog +import fr.free.nrw.commons.R +import timber.log.Timber +import java.io.File + +object FolderDeletionHelper { + + /** + * Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it. + * + * @param context The context used to show the confirmation dialog and manage deletion. + * @param folder The folder to be deleted. + * @param onDeletionComplete Callback invoked with `true` if the folder was + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. + * successfully deleted, `false` otherwise. + */ + fun confirmAndDeleteFolder( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher, + onDeletionComplete: (Boolean) -> Unit) { + + //don't show this dialog on API 30+, it's handled automatically using MediaStore + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val success = trashImagesInFolder(context, folder, trashFolderLauncher) + onDeletionComplete(success) + } else { + val imagePaths = listImagesInFolder(context, folder) + val imageCount = imagePaths.size + val folderPath = folder.absolutePath + + AlertDialog.Builder(context) + .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) + .setCancelable(false) + .setMessage( + context.getString( + R.string.custom_selector_confirm_deletion_message, + folderPath, + imageCount + ) + ) + .setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ -> + + //proceed with deletion if user confirms + val success = deleteImagesLegacy(imagePaths) + onDeletionComplete(success) + } + .setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ -> + dialog.dismiss() + onDeletionComplete(false) + } + .show() + } + } + + /** + * Moves all images in a specified folder (but not within its subfolders) to the trash on + * devices running Android 11 (API level 30) and above. + * + * @param context The context used to access the content resolver. + * @param folder The folder whose top-level images are to be moved to the trash. + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash + * request. + * @return `true` if the trash request was initiated successfully, `false` otherwise. + */ + private fun trashImagesInFolder( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher): Boolean + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val urisToTrash = mutableListOf() + + val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + // select images contained in the folder but not within subfolders + val selection = + "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?" + val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%") + + contentResolver.query( + mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection, + selectionArgs, null + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val fileUri = ContentUris.withAppendedId(mediaUri, id) + urisToTrash.add(fileUri) + } + } + + + //proceed with trashing if we have valid URIs + if (urisToTrash.isNotEmpty()) { + try { + val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true) + val intentSenderRequest = + IntentSenderRequest.Builder(trashRequest.intentSender).build() + trashFolderLauncher.launch(intentSenderRequest) + return true + } catch (e: SecurityException) { + Timber.tag("DeleteFolder").e( + context.getString( + R.string.custom_selector_error_trashing_folder_contents, + e.message + ) + ) + } + } + return false + } + + + /** + * Lists all image file paths in the specified folder, excluding any subfolders. + * + * @param context The context used to access the content resolver. + * @param folder The folder whose top-level images are to be listed. + * @return A list of file paths (as Strings) pointing to the images in the specified folder. + */ + private fun listImagesInFolder(context: Context, folder: File): List { + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val selection = + "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?" + val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%") + val imagePaths = mutableListOf() + + contentResolver.query( + mediaUri, arrayOf(MediaStore.MediaColumns.DATA), selection, + selectionArgs, null + )?.use { cursor -> + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + while (cursor.moveToNext()) { + val imagePath = cursor.getString(dataColumn) + imagePaths.add(imagePath) + } + } + return imagePaths + } + + + + /** + * Refreshes the MediaStore for a specified folder, updating the system to recognize any changes + * + * @param context The context used to access the MediaScannerConnection. + * @param folder The folder to refresh in the MediaStore. + */ + fun refreshMediaStore(context: Context, folder: File) { + MediaScannerConnection.scanFile( + context, + arrayOf(folder.absolutePath), + null + ) { _, _ -> } + } + + + + /** + * Deletes a list of image files specified by their paths, on + * Android 10 (API level 29) and below. + * + * @param imagePaths A list of absolute file paths to image files that need to be deleted. + * @return `true` if all the images are successfully deleted, `false` otherwise. + */ + private fun deleteImagesLegacy(imagePaths: List): Boolean { + var result = true + imagePaths.forEach { + val imageFile = File(it) + val deleted = imageFile.exists() && imageFile.delete() + result = result && deleted + } + return result + } + + + /** + * Retrieves the absolute path of a folder given its unique identifier (bucket ID). + * + * @param context The context used to access the content resolver. + * @param folderId The unique identifier (bucket ID) of the folder. + * @return The absolute path of the folder as a `String`, or `null` if the folder is not found. + */ + fun getFolderPath(context: Context, folderId: Long): String? { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?" + val selectionArgs = arrayOf(folderId.toString()) + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)) + return File(fullPath).parent + } + } + return null + } + + /** + * Displays an error message to the user and logs it for debugging purposes. + * + * @param context The context used to display the Toast. + * @param message The error message to display and log. + * @param folderName The name of the folder to delete. + */ + fun showError(context: Context, message: String, folderName: String) { + Toast.makeText(context, + context.getString(R.string.custom_selector_folder_deleted_failure, folderName), + Toast.LENGTH_SHORT).show() + Timber.tag("DeleteFolder").e(message) + } + + /** + * Displays a success message to the user. + * + * @param context The context used to display the Toast. + * @param message The success message to display. + * @param folderName The name of the folder to delete. + */ + fun showSuccess(context: Context, message: String, folderName: String) { + Toast.makeText(context, + context.getString(R.string.custom_selector_folder_deleted_success, folderName), + Toast.LENGTH_SHORT).show() + Timber.tag("DeleteFolder").d(message) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt index f59d8a844..5df123ad2 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image * Image Helper object, includes all the static functions and variables required by custom selector. */ object ImageHelper { - /** * Custom selector preference key */ @@ -39,7 +38,10 @@ object ImageHelper { /** * Filters the images based on the given bucketId (folder) */ - fun filterImages(images: ArrayList, bukketId: Long?): ArrayList { + fun filterImages( + images: ArrayList, + bukketId: Long?, + ): ArrayList { if (bukketId == null) return images val filteredImages = arrayListOf() @@ -54,30 +56,37 @@ object ImageHelper { /** * getIndex: Returns the index of image in given list. */ - fun getIndex(list: ArrayList, image: Image): Int { - return list.indexOf(image) - } + fun getIndex( + list: ArrayList, + image: Image, + ): Int = list.indexOf(image) /** * getIndex: Returns the index of image in given list. */ - fun getIndexFromId(list: ArrayList, imageId: Long): Int { - for(i in list){ - if(i.id == imageId) + fun getIndexFromId( + list: ArrayList, + imageId: Long, + ): Int { + for (i in list) { + if (i.id == imageId) { return list.indexOf(i) + } } - return 0; + return 0 } /** * Gets the list of indices from the master list. */ - fun getIndexList(list: ArrayList, masterList: ArrayList): ArrayList { - - // Can be optimised as masterList is sorted by time. + fun getIndexList( + list: ArrayList, + masterList: ArrayList, + ): ArrayList { + // Can be optimised as masterList is sorted by time. val indexes = arrayListOf() - for(image in list) { + for (image in list) { val index = getIndex(masterList, image) if (index == -1) { continue @@ -86,4 +95,4 @@ object ImageHelper { } return indexes } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt index f454a3af8..05159c56b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/OnSwipeTouchListener.kt @@ -2,23 +2,29 @@ package fr.free.nrw.commons.customselector.helper import android.content.Context import android.util.DisplayMetrics -import android.view.* +import android.view.Display +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager import kotlin.math.abs /** * Class for detecting swipe gestures */ -open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { - +open class OnSwipeTouchListener( + context: Context?, +) : View.OnTouchListener { private val gestureDetector: GestureDetector - private val SWIPE_THRESHOLD_HEIGHT = (getScreenResolution(context!!)).second / 3 - private val SWIPE_THRESHOLD_WIDTH = (getScreenResolution(context!!)).first / 3 - private val SWIPE_VELOCITY_THRESHOLD = 1000 + private val swipeThresholdHeight = (getScreenResolution(context!!)).second / 3 + private val swipeThresholdWidth = (getScreenResolution(context!!)).first / 3 + private val swipeVelocityThreshold = 1000 - override fun onTouch(view: View?, motionEvent: MotionEvent): Boolean { - return gestureDetector.onTouchEvent(motionEvent) - } + override fun onTouch( + view: View?, + motionEvent: MotionEvent, + ): Boolean = gestureDetector.onTouchEvent(motionEvent) fun getScreenResolution(context: Context): Pair { val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @@ -31,26 +37,25 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { } inner class GestureListener : GestureDetector.SimpleOnGestureListener() { - - override fun onDown(e: MotionEvent): Boolean { - return true - } + override fun onDown(e: MotionEvent): Boolean = true /** * Detects the gestures */ override fun onFling( - event1: MotionEvent, + event1: MotionEvent?, event2: MotionEvent, velocityX: Float, - velocityY: Float + velocityY: Float, ): Boolean { try { - val diffY: Float = event2.y - event1.y - val diffX: Float = event2.x - event1.x + val diffY: Float = event2.y - (event1?.y ?: event2.y) + val diffX: Float = event2.x - (event1?.x ?: event2.x) if (abs(diffX) > abs(diffY)) { - if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) > - SWIPE_VELOCITY_THRESHOLD) { + if (abs(diffX) > swipeThresholdWidth && + abs(velocityX) > + swipeVelocityThreshold + ) { if (diffX > 0) { onSwipeRight() } else { @@ -58,8 +63,10 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { } } } else { - if (abs(diffY) > SWIPE_THRESHOLD_HEIGHT && abs(velocityY) > - SWIPE_VELOCITY_THRESHOLD) { + if (abs(diffY) > swipeThresholdHeight && + abs(velocityY) > + swipeVelocityThreshold + ) { if (diffY > 0) { onSwipeDown() } else { @@ -100,4 +107,4 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { init { gestureDetector = GestureDetector(context, GestureListener()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt index bc3bd518d..be808c3f0 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/FolderClickListener.kt @@ -4,12 +4,15 @@ package fr.free.nrw.commons.customselector.listeners * Custom Selector Folder Click Listener */ interface FolderClickListener { - /** * onFolderClick * @param folderId : folder id of the folder. * @param folderName : folder name of the folder. * @param lastItemId : last scroll position in the folder. */ - fun onFolderClick(folderId: Long, folderName: String, lastItemId: Long) -} \ No newline at end of file + fun onFolderClick( + folderId: Long, + folderName: String, + lastItemId: Long, + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt index 5ba43082d..78ce46c6e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt @@ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image * responds to the device image query. */ interface ImageLoaderListener { - /** * On image loaded * @param images : queried device images. @@ -19,4 +18,4 @@ interface ImageLoaderListener { * @param throwable : throwable exception on failure. */ fun onFailed(throwable: Throwable) -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt index d6349eb49..24565963b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt @@ -1,19 +1,20 @@ package fr.free.nrw.commons.customselector.listeners -import android.net.Uri import fr.free.nrw.commons.customselector.model.Image /** * Custom selector Image select listener */ interface ImageSelectListener { - /** * onSelectedImagesChanged * @param selectedImages : new selected images. * @param selectedNotForUploadImages : number of selected not for upload images */ - fun onSelectedImagesChanged(selectedImages: ArrayList, selectedNotForUploadImages: Int) + fun onSelectedImagesChanged( + selectedImages: ArrayList, + selectedNotForUploadImages: Int, + ) /** * onLongPress @@ -22,6 +23,6 @@ interface ImageSelectListener { fun onLongPress( position: Int, images: ArrayList, - selectedImages: ArrayList + selectedImages: ArrayList, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt index d2110d208..da526be35 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt @@ -6,5 +6,8 @@ import fr.free.nrw.commons.customselector.model.Image * Interface to pass data between fragment and activity */ interface PassDataListener { - fun passSelectedImages(selectedImages: ArrayList, shouldRefresh: Boolean) -} \ No newline at end of file + fun passSelectedImages( + selectedImages: ArrayList, + shouldRefresh: Boolean, + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt index d271c1d0b..a6b4f3dce 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/RefreshUIListener.kt @@ -8,4 +8,4 @@ interface RefreshUIListener { * Refreshes the data in adapter */ fun refresh() -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt index 5cdcfb9bf..c47806f16 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt @@ -6,17 +6,17 @@ package fr.free.nrw.commons.customselector.model */ sealed class CallbackStatus { /** - IDLE : The callback is idle , doing nothing. + IDLE : The callback is idle , doing nothing. */ object IDLE : CallbackStatus() /** - FETCHING : Fetching images. + FETCHING : Fetching images. */ object FETCHING : CallbackStatus() /** - SUCCESS : Success fetching images. + SUCCESS : Success fetching images. */ object SUCCESS : CallbackStatus() -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt index 6857589bd..ec08f6f73 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt @@ -5,27 +5,22 @@ package fr.free.nrw.commons.customselector.model */ data class Folder( /** - bucketId : Unique directory id, eg 540528482 + bucketId : Unique directory id, eg 540528482 */ var bucketId: Long, - /** - name : bucket/folder name, eg Camera + name : bucket/folder name, eg Camera */ var name: String, - /** - images : folder images, list of all images under this folder. + images : folder images, list of all images under this folder. */ - var images: ArrayList = arrayListOf() - - + var images: ArrayList = arrayListOf(), ) { /** * Indicates whether some other object is "equal to" this one. */ override fun equals(other: Any?): Boolean { - if (javaClass != other?.javaClass) { return false } @@ -44,4 +39,4 @@ data class Folder( return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt index 38e7b6b85..a2965fb5d 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt @@ -9,65 +9,60 @@ import android.os.Parcelable */ data class Image( /** - id : Unique image id, primary key of image in device, eg 104950 + id : Unique image id, primary key of image in device, eg 104950 */ var id: Long, - /** - name : Name of the image with extension, eg CommonsLogo.jpeg + name : Name of the image with extension, eg CommonsLogo.jpeg */ var name: String, - /** - uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10) + uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10) */ var uri: Uri, - /** - path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg + path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg */ var path: String, - /** - bucketId : bucketId of folder, eg 540528482 + bucketId : bucketId of folder, eg 540528482 */ var bucketId: Long = 0, - /** - bucketName : name of folder, eg Camera + bucketName : name of folder, eg Camera */ var bucketName: String = "", - /** - sha1 : sha1 of original image. + sha1 : sha1 of original image. */ var sha1: String = "", - /** * date: Creation date of the image to show it inside the bubble during bubble scroll. */ - var date: String = "" - + var date: String = "", ) : Parcelable { + /** + default parcelable constructor. + */ + constructor(parcel: Parcel) : + this( + parcel.readLong(), + parcel.readString()!!, + parcel.readParcelable(Uri::class.java.classLoader)!!, + parcel.readString()!!, + parcel.readLong(), + parcel.readString()!!, + parcel.readString()!!, + parcel.readString()!!, + ) /** - default parcelable constructor. + Write to parcel method. */ - constructor(parcel: Parcel): - this(parcel.readLong(), - parcel.readString()!!, - parcel.readParcelable(Uri::class.java.classLoader)!!, - parcel.readString()!!, - parcel.readLong(), - parcel.readString()!!, - parcel.readString()!!, - parcel.readString()!! - ) - - /** - Write to parcel method. - */ - override fun writeToParcel(parcel: Parcel, flags: Int) { + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { parcel.writeLong(id) parcel.writeString(name) parcel.writeParcelable(uri, flags) @@ -81,41 +76,38 @@ data class Image( /** * Describe the kinds of special objects contained in this Parcelable */ - override fun describeContents(): Int { - return 0 - } + override fun describeContents(): Int = 0 /** * Indicates whether some other object is "equal to" this one. */ override fun equals(other: Any?): Boolean { - - if(javaClass != other?.javaClass) { + if (javaClass != other?.javaClass) { return false } other as Image - if(id != other.id) { - return false; + if (id != other.id) { + return false } - if(name != other.name) { - return false; + if (name != other.name) { + return false } - if(uri != other.uri) { - return false; + if (uri != other.uri) { + return false } - if(path != other.path) { - return false; + if (path != other.path) { + return false } - if(bucketId != other.bucketId) { - return false; + if (bucketId != other.bucketId) { + return false } - if(bucketName != other.bucketName) { - return false; + if (bucketName != other.bucketName) { + return false } - if(sha1 != other.sha1) { - return false; + if (sha1 != other.sha1) { + return false } return true @@ -125,12 +117,8 @@ data class Image( * Parcelable companion object */ companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): Image { - return Image(parcel) - } + override fun createFromParcel(parcel: Parcel): Image = Image(parcel) - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } + override fun newArray(size: Int): Array = arrayOfNulls(size) } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt b/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt index 11ed8ef00..5cccccae6 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt @@ -7,10 +7,9 @@ data class Result( /** * CallbackStatus : stores the result status */ - val status:CallbackStatus, - + val status: CallbackStatus, /** * Images : images retrieved */ - val images: ArrayList) { -} \ No newline at end of file + val images: ArrayList, +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt index 60d299491..87f68a3e1 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -21,14 +21,11 @@ class FolderAdapter( * Application context. */ context: Context, - /** * Folder Click listener for click events. */ - private val itemClickListener: FolderClickListener - + private val itemClickListener: FolderClickListener, ) : RecyclerViewAdapter(context) { - /** * List of folders. */ @@ -37,7 +34,10 @@ class FolderAdapter( /** * Create view holder, returns View holder item. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): FolderViewHolder { val itemView = inflater.inflate(R.layout.item_custom_selector_folder, parent, false) return FolderViewHolder(itemView) } @@ -45,30 +45,35 @@ class FolderAdapter( /** * Bind view holder, setup the item view, title, count and click listener */ - override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { + override fun onBindViewHolder( + holder: FolderViewHolder, + position: Int, + ) { val folder = folders[position] val toBeRemoved = ArrayList() - for(image in folder.images) { + for (image in folder.images) { // Remove all the top images that do not exist anymore - if(context.contentResolver.getType(image.uri) == null){ + if (context.contentResolver.getType(image.uri) == null) { // File not found toBeRemoved.add(image) } else { break } } - holder.image.setImageDrawable (null) + holder.image.setImageDrawable(null) folder.images.removeAll(toBeRemoved) val count = folder.images.size - if(count == 0) { + if (count == 0 && folders.size > 0) { // Folder is empty, remove folder from the adapter. - holder.itemView.post{ + holder.itemView.post { val updatePosition = folders.indexOf(folder) - folders.removeAt(updatePosition) - notifyItemRemoved(updatePosition) - notifyItemRangeChanged(updatePosition, folders.size) + if (updatePosition != -1) { + folders.removeAt(updatePosition) + notifyItemRemoved(updatePosition) + notifyItemRangeChanged(updatePosition, folders.size) + } } } else { val previewImage = folder.images[0] @@ -87,9 +92,10 @@ class FolderAdapter( fun init(newFolders: List) { val oldFolderList: MutableList = folders val newFolderList = newFolders.toMutableList() - val diffResult = DiffUtil.calculateDiff( - FoldersDiffCallback(oldFolderList, newFolderList) - ) + val diffResult = + DiffUtil.calculateDiff( + FoldersDiffCallback(oldFolderList, newFolderList), + ) folders = newFolderList diffResult.dispatchUpdatesTo(this) } @@ -97,15 +103,14 @@ class FolderAdapter( /** * returns item count. */ - override fun getItemCount(): Int { - return folders.size - } + override fun getItemCount(): Int = folders.size /** * Folder view holder. */ - class FolderViewHolder(itemView:View) : RecyclerView.ViewHolder(itemView) { - + class FolderViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { /** * Folder thumbnail image view. */ @@ -127,37 +132,33 @@ class FolderAdapter( */ class FoldersDiffCallback( var oldFolders: MutableList, - var newFolders: MutableList + var newFolders: MutableList, ) : DiffUtil.Callback() { /** * Returns the size of the old list. */ - override fun getOldListSize(): Int { - return oldFolders.size - } + override fun getOldListSize(): Int = oldFolders.size /** * Returns the size of the new list. */ - override fun getNewListSize(): Int { - return newFolders.size - } + override fun getNewListSize(): Int = newFolders.size /** * Called by the DiffUtil to decide whether two object represent the same Item. */ - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId - } + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId /** * Called by the DiffUtil when it wants to check whether two items have the same data. * DiffUtil uses this information to detect if the contents of an item has changed. */ - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) - } - + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt index 47784153e..ff623d496 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -5,7 +5,6 @@ import android.content.SharedPreferences import android.view.View import android.view.ViewGroup import android.widget.ImageView -import android.widget.TextView import android.widget.Toast import androidx.constraintlayout.widget.Group import androidx.recyclerview.widget.DiffUtil @@ -20,8 +19,14 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTION import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.selector.ImageLoader -import kotlinx.coroutines.* -import java.util.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.util.TreeMap import kotlin.collections.ArrayList /** @@ -32,20 +37,16 @@ class ImageAdapter( * Application Context. */ context: Context, - /** * Image select listener for click events on image. */ private var imageSelectListener: ImageSelectListener, - /** * ImageLoader queries images. */ - private var imageLoader: ImageLoader -): - - RecyclerViewAdapter(context), FastScrollRecyclerView.SectionedAdapter { - + private var imageLoader: ImageLoader, +) : RecyclerViewAdapter(context), + FastScrollRecyclerView.SectionedAdapter { /** * ImageSelectedOrUpdated payload class. */ @@ -103,17 +104,32 @@ class ImageAdapter( */ private var imagePositionAsPerIncreasingOrder = 0 + /** + * Stores the number of images currently visible on the screen + */ + private val _currentImagesCount = MutableStateFlow(0) + val currentImagesCount = _currentImagesCount + + /** + * Stores whether images are being loaded or not + */ + private val _isLoadingImages = MutableStateFlow(false) + val isLoadingImages = _isLoadingImages + /** * Coroutine Dispatchers and Scope. */ - private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default - private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO - private val scope : CoroutineScope = MainScope() + private var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default + private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + private val scope: CoroutineScope = MainScope() /** * Create View holder. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ImageViewHolder { val itemView = inflater.inflate(R.layout.item_custom_selector_image, parent, false) return ImageViewHolder(itemView) } @@ -121,10 +137,15 @@ class ImageAdapter( /** * Bind View holder, load image, selected view, click listeners. */ - override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { - - var image=images[position] - holder.image.setImageDrawable (null) + override fun onBindViewHolder( + holder: ImageViewHolder, + position: Int, + ) { + if (images.size == 0) { + return + } + var image = images[position] + holder.image.setImageDrawable(null) if (context.contentResolver.getType(image.uri) == null) { // Image does not exist anymore, update adapter. holder.itemView.post { @@ -140,18 +161,19 @@ class ImageAdapter( sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) // Getting selected index when switch is on - val selectedIndex: Int = if (showAlreadyActionedImages) { - ImageHelper.getIndex(selectedImages, image) + val selectedIndex: Int = + if (showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, image) - // Getting selected index when switch is off - } else if (actionableImagesMap.size > position) { - ImageHelper - .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + // Getting selected index when switch is off + } else if (actionableImagesMap.size > position) { + ImageHelper + .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) - // For any other case return -1 - } else { - -1 - } + // For any other case return -1 + } else { + -1 + } val isSelected = selectedIndex != -1 if (isSelected) { @@ -160,7 +182,11 @@ class ImageAdapter( holder.itemUnselected() } imageLoader.queryAndSetView( - holder, image, ioDispatcher, defaultDispatcher ,uploadingContributionList + holder, + image, + ioDispatcher, + defaultDispatcher, + uploadingContributionList, ) scope.launch { val sharedPreferences: SharedPreferences = @@ -171,24 +197,34 @@ class ImageAdapter( // If the position is not already visited, that means the position is new then // finds the next actionable image position from all images if (!alreadyAddedPositions.contains(position)) { - processThumbnailForActionedImage(holder, position, uploadingContributionList) - - // If the position is already visited, that means the image is already present - // inside map, so it will fetch the image from the map and load in the holder + processThumbnailForActionedImage( + holder, + position, + uploadingContributionList + ) + _isLoadingImages.value = false + // If the position is already visited, that means the image is already present + // inside map, so it will fetch the image from the map and load in the holder } else { val actionableImages: List = ArrayList(actionableImagesMap.values) - if(actionableImages.size > position) { + if (actionableImages.size > position) { image = actionableImages[position] - Glide.with(holder.image).load(image.uri) - .thumbnail(0.3f).into(holder.image) + Glide + .with(holder.image) + .load(image.uri) + .thumbnail(0.3f) + .into(holder.image) } } - // If switch is turned off, it just fetches the image from all images without any - // further operations + // If switch is turned off, it just fetches the image from all images without any + // further operations } else { - Glide.with(holder.image).load(image.uri) - .thumbnail(0.3f).into(holder.image) + Glide + .with(holder.image) + .load(image.uri) + .thumbnail(0.3f) + .into(holder.image) } } @@ -210,12 +246,17 @@ class ImageAdapter( suspend fun processThumbnailForActionedImage( holder: ImageViewHolder, position: Int, - uploadingContributionList: List + uploadingContributionList: List, ) { - val next = imageLoader.nextActionableImage( - allImages, ioDispatcher, defaultDispatcher, - nextImagePosition, uploadingContributionList - ) + _isLoadingImages.value = true + val next = + imageLoader.nextActionableImage( + allImages, + ioDispatcher, + defaultDispatcher, + nextImagePosition, + uploadingContributionList, + ) // If next actionable image is found, saves it, as the the search for // finding next actionable image will start from this position @@ -229,8 +270,12 @@ class ImageAdapter( actionableImagesMap[next] = allImages[next] alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) imagePositionAsPerIncreasingOrder++ - Glide.with(holder.image).load(allImages[next].uri) - .thumbnail(0.3f).into(holder.image) + _currentImagesCount.value = imagePositionAsPerIncreasingOrder + Glide + .with(holder.image) + .load(allImages[next].uri) + .thumbnail(0.3f) + .into(holder.image) notifyItemInserted(position) notifyItemRangeChanged(position, itemCount + 1) } @@ -241,6 +286,7 @@ class ImageAdapter( reachedEndOfFolder = true notifyItemRemoved(position) } + _isLoadingImages.value = false } /** @@ -248,7 +294,7 @@ class ImageAdapter( */ private fun onThumbnailClicked( position: Int, - holder: ImageViewHolder + holder: ImageViewHolder, ) { val sharedPreferences: SharedPreferences = context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) @@ -269,7 +315,10 @@ class ImageAdapter( /** * Handle click event on an image, update counter on images. */ - private fun selectOrRemoveImage(holder: ImageViewHolder, position: Int){ + private fun selectOrRemoveImage( + holder: ImageViewHolder, + position: Int, + ) { val sharedPreferences: SharedPreferences = context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) val showAlreadyActionedImages = @@ -277,14 +326,15 @@ class ImageAdapter( // Getting clicked index from all images index when show_already_actioned_images // switch is on - val clickedIndex: Int = if(showAlreadyActionedImages) { - ImageHelper.getIndex(selectedImages, images[position]) + val clickedIndex: Int = + if (showAlreadyActionedImages) { + ImageHelper.getIndex(selectedImages, images[position]) - // Getting clicked index from actionable images when show_already_actioned_images - // switch is off - } else { - ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) - } + // Getting clicked index from actionable images when show_already_actioned_images + // switch is off + } else { + ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) + } if (clickedIndex != -1) { selectedImages.removeAt(clickedIndex) @@ -294,13 +344,14 @@ class ImageAdapter( notifyItemChanged(position, ImageUnselected()) // Getting index from all images index when switch is on - val indexes = if (showAlreadyActionedImages) { - ImageHelper.getIndexList(selectedImages, images) + val indexes = + if (showAlreadyActionedImages) { + ImageHelper.getIndexList(selectedImages, images) - // Getting index from actionable images when switch is off - } else { - ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) - } + // Getting index from actionable images when switch is off + } else { + ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) + } for (index in indexes) { notifyItemChanged(index, ImageSelectedOrUpdated()) } @@ -313,15 +364,16 @@ class ImageAdapter( } // Getting index from all images index when switch is on - val indexes: ArrayList = if (showAlreadyActionedImages) { - selectedImages.add(images[position]) - ImageHelper.getIndexList(selectedImages, images) + val indexes: ArrayList = + if (showAlreadyActionedImages) { + selectedImages.add(images[position]) + ImageHelper.getIndexList(selectedImages, images) - // Getting index from actionable images when switch is off - } else { - selectedImages.add(ArrayList(actionableImagesMap.values)[position]) - ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) - } + // Getting index from actionable images when switch is off + } else { + selectedImages.add(ArrayList(actionableImagesMap.values)[position]) + ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) + } for (index in indexes) { notifyItemChanged(index, ImageSelectedOrUpdated()) @@ -334,10 +386,16 @@ class ImageAdapter( /** * Initialize the data set. */ - fun init(newImages: List, fixedImages: List, emptyMap: TreeMap, uploadedImages: List = ArrayList()) { + fun init( + newImages: List, + fixedImages: List, + emptyMap: TreeMap, + uploadedImages: List = ArrayList(), + ) { + _isLoadingImages.value = true allImages = fixedImages - val oldImageList:ArrayList = images - val newImageList:ArrayList = ArrayList(newImages) + val oldImageList: ArrayList = images + val newImageList: ArrayList = ArrayList(newImages) actionableImagesMap = emptyMap alreadyAddedPositions = ArrayList() uploadingContributionList = uploadedImages @@ -345,9 +403,11 @@ class ImageAdapter( reachedEndOfFolder = false selectedImages = ArrayList() imagePositionAsPerIncreasingOrder = 0 - val diffResult = DiffUtil.calculateDiff( - ImagesDiffCallback(oldImageList, newImageList) - ) + _currentImagesCount.value = imagePositionAsPerIncreasingOrder + val diffResult = + DiffUtil.calculateDiff( + ImagesDiffCallback(oldImageList, newImageList), + ) images = newImageList diffResult.dispatchUpdatesTo(this) } @@ -355,31 +415,35 @@ class ImageAdapter( /** * Set new selected images */ - fun setSelectedImages(newSelectedImages: ArrayList){ + fun setSelectedImages(newSelectedImages: ArrayList) { selectedImages = ArrayList(newSelectedImages) imageSelectListener.onSelectedImagesChanged(selectedImages, 0) } + /** * Refresh the data in the adapter */ - fun refresh(newImages: List, fixedImages: List, uploadingImages: List = ArrayList()) { + fun refresh( + newImages: List, + fixedImages: List, + uploadingImages: List = ArrayList(), + ) { numberOfSelectedImagesMarkedAsNotForUpload = 0 images.clear() selectedImages = arrayListOf() - init(newImages, fixedImages, TreeMap(),uploadingImages) + init(newImages, fixedImages, TreeMap(), uploadingImages) notifyDataSetChanged() } /** * Clear selected images and empty the list. */ - fun clearSelectedImages(){ + fun clearSelectedImages() { numberOfSelectedImagesMarkedAsNotForUpload = 0 selectedImages.clear() selectedImages = arrayListOf() } - /** * Remove image from actionable images map. */ @@ -389,7 +453,7 @@ class ImageAdapter( val showAlreadyActionedImages = sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) - if(showAlreadyActionedImages) { + if (showAlreadyActionedImages) { refresh(allImages, allImages, uploadingContributionList) } else { val iterator = actionableImagesMap.entries.iterator() @@ -399,19 +463,18 @@ class ImageAdapter( val entry = iterator.next() if (entry.value == image) { imagePositionAsPerIncreasingOrder -= 1 + _currentImagesCount.value = imagePositionAsPerIncreasingOrder iterator.remove() alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1) notifyItemRemoved(index) - notifyItemRangeChanged(index, itemCount ) + notifyItemRangeChanged(index, itemCount) break } index++ } } - } - /** * Returns the total number of items in the data set held by the adapter. * @@ -424,24 +487,22 @@ class ImageAdapter( sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) // While switch is on initializes the holder with all images size - return if(showAlreadyActionedImages) { + return if (showAlreadyActionedImages) { allImages.size - // While switch is off and searching for next actionable has ended, initializes the holder - // with size of all actionable images + // While switch is off and searching for next actionable has ended, initializes the holder + // with size of all actionable images } else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) { actionableImagesMap.size - // While switch is off, initializes the holder with and extra view holder so that finding - // and addition of the next actionable image in the adapter can be continued + // While switch is off, initializes the holder with and extra view holder so that finding + // and addition of the next actionable image in the adapter can be continued } else { actionableImagesMap.size + 1 } } - fun getImageIdAt(position: Int): Long { - return images.get(position).id - } + fun getImageIdAt(position: Int): Long = images.get(position).id /** * CleanUp function. @@ -453,7 +514,9 @@ class ImageAdapter( /** * Image view holder. */ - class ImageViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + class ImageViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { val image: ImageView = itemView.findViewById(R.id.image_thumbnail) private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group) private val uploadingGroup: Group = itemView.findViewById(R.id.uploading_group) @@ -495,16 +558,12 @@ class ImageAdapter( notForUploadGroup.visibility = View.VISIBLE } - fun isItemUploaded():Boolean { - return uploadedGroup.visibility == View.VISIBLE - } + fun isItemUploaded(): Boolean = uploadedGroup.visibility == View.VISIBLE /** * Item is not for upload */ - fun isItemNotForUpload():Boolean { - return notForUploadGroup.visibility == View.VISIBLE - } + fun isItemNotForUpload(): Boolean = notForUploadGroup.visibility == View.VISIBLE /** * Item is not uploading @@ -533,45 +592,38 @@ class ImageAdapter( */ class ImagesDiffCallback( var oldImageList: ArrayList, - var newImageList: ArrayList - ) : DiffUtil.Callback(){ - + var newImageList: ArrayList, + ) : DiffUtil.Callback() { /** * Returns the size of the old list. */ - override fun getOldListSize(): Int { - return oldImageList.size - } + override fun getOldListSize(): Int = oldImageList.size /** * Returns the size of the new list. */ - override fun getNewListSize(): Int { - return newImageList.size - } + override fun getNewListSize(): Int = newImageList.size /** * Called by the DiffUtil to decide whether two object represent the same Item. */ - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return newImageList[newItemPosition].id == oldImageList[oldItemPosition].id - } + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = newImageList[newItemPosition].id == oldImageList[oldItemPosition].id /** * Called by the DiffUtil when it wants to check whether two items have the same data. * DiffUtil uses this information to detect if the contents of an item has changed. */ - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) - } - + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) } /** * Returns the text for showing inside the bubble during bubble scroll. */ - override fun getSectionName(position: Int): String { - return images[position].date - } - + override fun getSectionName(position: Int): String = images[position].date } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt index 75f935302..3318d5890 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/RecyclerViewAdapter.kt @@ -7,6 +7,8 @@ import androidx.recyclerview.widget.RecyclerView /** * Generic Recycler view adapter. */ -abstract class RecyclerViewAdapter(val context: Context): RecyclerView.Adapter() { +abstract class RecyclerViewAdapter( + val context: Context, +) : RecyclerView.Adapter() { val inflater: LayoutInflater = LayoutInflater.from(context) -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 7cf0229cb..4e2d58bab 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -1,44 +1,78 @@ package fr.free.nrw.commons.customselector.ui.selector +import android.Manifest import android.app.Activity import android.app.Dialog import android.content.Intent import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.view.View import android.view.Window import android.widget.Button import android.widget.ImageButton +import android.widget.PopupMenu import android.widget.TextView +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants -import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH +import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding -import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.utils.CustomSelectorUtils -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.lang.Integer.max import javax.inject.Inject - /** * Custom Selector Activity. */ -class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectListener { - +class CustomSelectorActivity : + BaseActivity(), + FolderClickListener, + ImageSelectListener { /** * ViewBindings */ @@ -112,23 +146,66 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL */ var imageFragment: ImageFragment? = null - private var progressDialogText:String="" + private var progressDialogText: String = "" + + private var showPartialAccessIndicator by mutableStateOf(false) + + /** + * Show delete button in folder + */ + private var showOverflowMenu = false + + /** + * Waits for confirmation of delete folder + */ + private val startForFolderDeletionResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()){ + result -> onDeleteFolderResultReceived(result) + } + + private val startForResult = registerForActivityResult(StartActivityForResult()){ result -> + onFullScreenDataReceived(result) + } + /** * onCreate Activity, sets theme, initialises the view model, setup view. */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_MEDIA_IMAGES, + ) == PackageManager.PERMISSION_DENIED + ) { + showPartialAccessIndicator = true + } + binding = ActivityCustomSelectorBinding.inflate(layoutInflater) toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) + binding.partialAccessIndicator.setContent { + partialStorageAccessIndicator( + isVisible = showPartialAccessIndicator, + onManage = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) + } + }, + modifier = + Modifier + .padding(vertical = 8.dp, horizontal = 4.dp) + .fillMaxWidth(), + ) + } val view = binding.root setContentView(view) prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) - viewModel = ViewModelProvider(this, customSelectorViewModelFactory).get( - CustomSelectorViewModel::class.java - ) + viewModel = + ViewModelProvider(this, customSelectorViewModelFactory).get( + CustomSelectorViewModel::class.java, + ) setupViews() @@ -147,30 +224,54 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL } } + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showPartialAccessIndicator = false + } + } + } + + override fun onResume() { + super.onResume() + fetchData() + } + /** * When data will be send from full screen mode, it will be passed to fragment */ - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE && - resultCode == Activity.RESULT_OK - ) { + private fun onFullScreenDataReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK) { val selectedImages: ArrayList = - data!! + result.data!! .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! - val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false) - imageFragment?.passSelectedImages(selectedImages, shouldRefresh) + viewModel.selectedImages?.value = selectedImages } } + private fun onDeleteFolderResultReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK){ + FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) + navigateToCustomSelector() + } + } + + + /** * Show Custom Selector Welcome Dialog. */ private fun showWelcomeDialog() { val dialog = Dialog(this) + dialog.setCancelable(false) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.setContentView(R.layout.custom_selector_info_dialog) - (dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() } + (dialog.findViewById